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

knowledgepixels / nanopub-query / 36121009261

25 Sep 2026 09:54AM UTC coverage: 77.627% (+0.2%) from 77.407%
36121009261

push

github

web-flow
Merge pull request #228 from knowledgepixels/fix/authority-resolver-query-hardening

fix(spaces): gate invalidation checks on the delta and cap their server time

935 of 1352 branches covered (69.16%)

Branch coverage included in aggregate %.

2677 of 3301 relevant lines covered (81.1%)

12.71 hits per line

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

91.35
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.BooleanQuery;
20
import org.eclipse.rdf4j.query.QueryLanguage;
21
import org.eclipse.rdf4j.query.TupleQueryResult;
22
import org.eclipse.rdf4j.query.Update;
23
import org.eclipse.rdf4j.repository.RepositoryConnection;
24
import org.eclipse.rdf4j.repository.RepositoryResult;
25
import org.nanopub.vocabulary.NPA;
26
import org.nanopub.vocabulary.NPX;
27
import org.slf4j.Logger;
28
import org.slf4j.LoggerFactory;
29

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

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

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

66
    private static final ValueFactory vf = SimpleValueFactory.getInstance();
6 ✔
67

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

73
    /**
74
     * Server-side limit, in seconds, on the invalidation checks, invalidation deletes and
75
     * tier inserts this class sends to the spaces repo. Equal to the client's socket read
76
     * timeout, so it never cuts off a request that could succeed today; its job is to stop
77
     * rdf4j from evaluating a request the client has already given up on. Without it, a
78
     * check that never finished on 2026-09-24 (rdf4j 6.1.0) kept running server-side while
79
     * every tick retried it, until dozens of copies pegged rdf4j on every host. An update
80
     * aborted by the limit rolls back as a whole.
81
     */
82
    private static final int SERVER_TIME_LIMIT_SECONDS = TripleStore.SOCKET_TIMEOUT_SECONDS;
83

84
    /** NPA constants pulled in locally (trust-side). */
85
    private static final IRI NPA_HAS_CURRENT_TRUST_STATE =
9 ✔
86
            vf.createIRI(NPA.NAMESPACE, "hasCurrentTrustState");
6 ✔
87
    private static final IRI NPA_ACCOUNT_STATE = vf.createIRI(NPA.NAMESPACE, "AccountState");
15 ✔
88
    private static final IRI NPA_AGENT = vf.createIRI(NPA.NAMESPACE, "agent");
15 ✔
89
    private static final IRI NPA_PUBKEY = vf.createIRI(NPA.NAMESPACE, "pubkey");
15 ✔
90
    private static final IRI NPA_TRUST_STATUS = vf.createIRI(NPA.NAMESPACE, "trustStatus");
15 ✔
91
    private static final IRI NPA_VIA_NANOPUB = vf.createIRI(NPA.NAMESPACE, "viaNanopub");
15 ✔
92
    private static final IRI NPA_LOADED = vf.createIRI(NPA.NAMESPACE, "loaded");
15 ✔
93
    private static final IRI NPA_TO_LOAD = vf.createIRI(NPA.NAMESPACE, "toLoad");
15 ✔
94

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

116
    /**
117
     * Trust-approved set: rows with one of these {@code npa:trustStatus} values
118
     * are mirrored into the space-state graph. Per
119
     * {@code doc/design-trust-state-repos.md}, these are the two "authority-
120
     * approving" statuses; {@code npa:contested}, {@code npa:skipped}, and the
121
     * transient statuses are distinct values of the same predicate and are
122
     * excluded automatically by this positive-list filter.
123
     */
124
    private static final Set<IRI> APPROVED_SET = Set.of(NPA_LOADED, NPA_TO_LOAD);
15 ✔
125

126
    private static AuthorityResolver instance;
127

128
    /** Returns the singleton. */
129
    public static synchronized AuthorityResolver get() {
130
        if (instance == null) {
6 ✔
131
            instance = new AuthorityResolver();
12 ✔
132
        }
133
        return instance;
6 ✔
134
    }
135

136
    private AuthorityResolver() {
6 ✔
137
    }
3 ✔
138

139
    // ---------------- Operational metrics snapshot ----------------
140
    //
141
    // Updated at the end of each runFullBuild / runIncrementalCycle, read by
142
    // MetricsCollector via the get*() accessors below. volatile is enough —
143
    // writers serialise via the synchronized methods, and readers (Prometheus
144
    // scrapes) only need most-recent visibility, not transactional consistency
145
    // across the snapshot. Defaults to zero values so a scrape that races a
146
    // boot before the first cycle returns 0, not NaN.
147

148
    private volatile TierSubjectTotals lastSubjectTotals = new TierSubjectTotals(0L, 0L, 0L);
24 ✔
149
    private volatile long lastInsertedTriplesTotal;
150
    private volatile long lastFullBuildDurationMs;
151
    private volatile long lastIncrementalCycleDurationMs;
152
    private volatile long lastProcessedUpToLag;
153

154
    public TierSubjectTotals getLastSubjectTotals() { return lastSubjectTotals; }
9 ✔
155
    public long getLastInsertedTriplesTotal() { return lastInsertedTriplesTotal; }
9 ✔
156
    public long getLastFullBuildDurationMs() { return lastFullBuildDurationMs; }
9 ✔
157
    public long getLastIncrementalCycleDurationMs() { return lastIncrementalCycleDurationMs; }
9 ✔
158
    public long getLastProcessedUpToLag() { return lastProcessedUpToLag; }
9 ✔
159

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

184
    // ---------------- Public entry points ----------------
185

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

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

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

318
    // ---------------- Full build ----------------
319

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

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

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

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

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

403
        // 3. Stamp processedUpTo inside the new graph.
404
        writeProcessedUpTo(newGraph, loadCounter);
12 ✔
405

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

411
        // 4. Flip the current-space-state pointer.
412
        flipPointer(newGraph);
9 ✔
413

414
        // 5. Drop the old graph if a *different* one existed. Dropping it when
415
        //    rebuilding in place would delete what we just built.
416
        if (oldGraph != null && !rebuildInPlace) {
12 ✔
417
            dropGraph(oldGraph);
9 ✔
418
        }
419

420
        TierSubjectTotals totals = computeTierSubjectTotals(newGraph);
12 ✔
421
        long durationMs = (System.nanoTime() - startNanos) / 1_000_000L;
18 ✔
422
        lastSubjectTotals = totals;
9 ✔
423
        lastInsertedTriplesTotal = insertedTotal;
9 ✔
424
        lastFullBuildDurationMs = durationMs;
9 ✔
425
        lastProcessedUpToLag = 0L;
9 ✔
426
        logger.info("AuthorityResolver: full build complete — graph={} mirrored={} rows pending={} rows loadCounter={} "
36 ✔
427
                        + "subjects: adminRIs={} attachmentRAs={} nonAdminRIs={} "
428
                        + "(inserted-triples: admin={} alias={} preset-attachment={} preset-assignment-ref={} attachment={} maintainer={} member={} observer={} "
429
                        + "subspace={} subspace-prefix={} maintained-resource={} governing-space-ref={}) durationMs={}",
430
                newGraph, mirrored, pendingMirrored, loadCounter,
45 ✔
431
                totals.adminRIs(), totals.attachmentRAs(), totals.nonAdminRIs(),
57 ✔
432
                counts.admin, counts.alias, counts.presetAttachment, counts.presetAssignmentRef, counts.attachment, counts.maintainer, counts.member, counts.observer,
144 ✔
433
                counts.subSpace, counts.subSpacePrefix, counts.maintainedResource, counts.governingSpaceRef,
69 ✔
434
                durationMs);
6 ✔
435
    }
3 ✔
436

437
    // ---------------- Incremental cycle ----------------
438

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

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

514
        writeProcessedUpTo(graph, currentLoadCounter);
12 ✔
515
        // Re-stamp the integrity count after this cycle's mutations (also
516
        // upgrades pre-stamp graphs to verifiable on their first mutation).
517
        writeStateTripleCount(graph);
9 ✔
518

519
        TierSubjectTotals totals = computeTierSubjectTotals(graph);
12 ✔
520
        long durationMs = (System.nanoTime() - startNanos) / 1_000_000L;
18 ✔
521
        lastSubjectTotals = totals;
9 ✔
522
        lastInsertedTriplesTotal = (long) counts.admin + counts.alias + counts.presetAttachment
147 ✔
523
                + counts.presetAssignmentRef
524
                + counts.attachment + counts.maintainer + counts.member + counts.observer
525
                + counts.subSpace + counts.subSpacePrefix + counts.maintainedResource
526
                + counts.governingSpaceRef;
527
        lastIncrementalCycleDurationMs = durationMs;
9 ✔
528
        logger.info("AuthorityResolver: incremental cycle complete — graph={} delta=({}, {}] pending={} rows "
36 ✔
529
                        + "subjects: adminRIs={} attachmentRAs={} nonAdminRIs={} "
530
                        + "(inserted-triples: admin={} alias={} preset-attachment={} preset-assignment-ref={} attachment={} maintainer={} member={} observer={} "
531
                        + "subspace={} subspace-prefix={} maintained-resource={} governing-space-ref={}) "
532
                        + "structuralInvalidation={} structuralAdds={} durationMs={}",
533
                graph, lastProcessed, currentLoadCounter, pendingMirrored,
45 ✔
534
                totals.adminRIs(), totals.attachmentRAs(), totals.nonAdminRIs(),
57 ✔
535
                counts.admin, counts.alias, counts.presetAttachment, counts.presetAssignmentRef, counts.attachment, counts.maintainer, counts.member, counts.observer,
144 ✔
536
                counts.subSpace, counts.subSpacePrefix, counts.maintainedResource, counts.governingSpaceRef,
69 ✔
537
                structuralInvalidation, structuralAdds, durationMs);
36 ✔
538
    }
3 ✔
539

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

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

725
    /**
726
     * Cheap ASK: did any new {@code npa:RoleDeclaration} extraction land in the
727
     * load-number delta {@code (lastProcessed, ∞)}? Used by the late-arrival
728
     * trigger so an RD that arrives in the same cycle as a matching candidate
729
     * still gets validated.
730
     */
731
    boolean newRoleDeclarationsArrived(long lastProcessed) {
732
        String ask = String.format("""
60 ✔
733
                PREFIX npa: <%1$s>
734
                ASK {
735
                  GRAPH <%2$s> {
736
                    ?rd a npa:RoleDeclaration ;
737
                        npa:viaNanopub ?np .
738
                  }
739
                  GRAPH <%3$s> {
740
                    ?np npa:hasLoadNumber ?ln .
741
                    FILTER (?ln > %4$d)
742
                  }
743
                }
744
                """, NPA.NAMESPACE, SpacesVocab.SPACES_GRAPH, NPA.GRAPH, lastProcessed);
6 ✔
745
        return runAsk(ask);
12 ✔
746
    }
747

748
    /**
749
     * Cheap ASK: did any new {@code npa:PresetAssignment} or {@code npa:PresetDeclaration}
750
     * extraction land in the load-number delta {@code (lastProcessed, ∞)}? Drives the
751
     * late-arrival re-run so a preset assignment that arrives in the same cycle as its
752
     * declaration (or admin grant) still materializes, and so an arriving newer assignment
753
     * triggers the deactivation/latest-wins re-evaluation.
754
     */
755
    boolean newPresetAssignmentsArrived(long lastProcessed) {
756
        String ask = String.format("""
60 ✔
757
                PREFIX npa: <%1$s>
758
                ASK {
759
                  GRAPH <%2$s> {
760
                    ?x a ?t ;
761
                       npa:viaNanopub ?np .
762
                    FILTER (?t = npa:PresetAssignment || ?t = npa:PresetDeclaration)
763
                  }
764
                  GRAPH <%3$s> {
765
                    ?np npa:hasLoadNumber ?ln .
766
                    FILTER (?ln > %4$d)
767
                  }
768
                }
769
                """, NPA.NAMESPACE, SpacesVocab.SPACES_GRAPH, NPA.GRAPH, lastProcessed);
6 ✔
770
        return runAsk(ask);
12 ✔
771
    }
772

773
    /**
774
     * Which kinds of record the load-number delta {@code (lastProcessed, ∞)} holds, as far
775
     * as {@link #applyInvalidations} is concerned. Every invalidation check joins its delta
776
     * nanopub to exactly one of these kinds inside its own WHERE clause, and each gate below
777
     * is that sub-pattern on its own. A check can therefore only match when its gate does,
778
     * so skipping the check on a false gate is exact, not a heuristic.
779
     *
780
     * <p>Most cycles bring none of these (an admin grant, a role assignment, a new space),
781
     * yet every check used to be evaluated anyway. The checks are large joins whose plans
782
     * hinge on rdf4j's cardinality estimates: on 2026-09-24 the preset-deactivation check
783
     * never finished on rdf4j 6.1.0 after a routine admin grant and took the fleet down.
784
     * The gates are two-pattern lookups that plan the same on any version.
785
     *
786
     * @param invalidators      a nanopub with an {@code npx:invalidates} triple
787
     * @param presetAssignments an {@code npa:PresetAssignment} extraction
788
     * @param roleRevocations   an {@code npa:RoleRevocation} extraction
789
     * @param roleDetachments   an {@code npa:RoleDetachment} extraction
790
     */
791
    record DeltaKinds(boolean invalidators, boolean presetAssignments,
45 ✔
792
                      boolean roleRevocations, boolean roleDetachments) {
793
    }
794

795
    /** Reads the {@link DeltaKinds} of the delta {@code (lastProcessed, ∞)}. */
796
    DeltaKinds readDeltaKinds(long lastProcessed) {
797
        return new DeltaKinds(
15 ✔
798
                invalidatorsArrived(lastProcessed),
12 ✔
799
                extractionsArrived(SpacesVocab.PRESET_ASSIGNMENT, lastProcessed),
12 ✔
800
                extractionsArrived(SpacesVocab.ROLE_REVOCATION, lastProcessed),
12 ✔
801
                extractionsArrived(SpacesVocab.ROLE_DETACHMENT, lastProcessed));
6 ✔
802
    }
803

804
    /**
805
     * Cheap ASK: did a nanopub with an {@code npx:invalidates} triple land in the delta
806
     * {@code (lastProcessed, ∞)}? The gate of every {@code npx:invalidates}-based check and
807
     * delete in {@link #applyInvalidations}.
808
     */
809
    boolean invalidatorsArrived(long lastProcessed) {
810
        String ask = String.format("""
60 ✔
811
                PREFIX npa: <%1$s>
812
                ASK {
813
                  GRAPH <%2$s> {
814
                    ?invNp <%3$s> ?target ;
815
                           npa:hasLoadNumber ?ln .
816
                    FILTER (?ln > %4$d)
817
                  }
818
                }
819
                """, NPA.NAMESPACE, NPA.GRAPH, NPX.INVALIDATES, lastProcessed);
6 ✔
820
        return runAsk(ask);
12 ✔
821
    }
822

823
    /**
824
     * Cheap ASK: did an extraction of the given type land in the delta
825
     * {@code (lastProcessed, ∞)}?
826
     */
827
    boolean extractionsArrived(IRI type, long lastProcessed) {
828
        String ask = String.format("""
72 ✔
829
                PREFIX npa: <%1$s>
830
                ASK {
831
                  GRAPH <%2$s> {
832
                    ?x a <%3$s> ;
833
                       npa:viaNanopub ?np .
834
                  }
835
                  GRAPH <%4$s> {
836
                    ?np npa:hasLoadNumber ?ln .
837
                    FILTER (?ln > %5$d)
838
                  }
839
                }
840
                """, NPA.NAMESPACE, SpacesVocab.SPACES_GRAPH, type, NPA.GRAPH, lastProcessed);
6 ✔
841
        return runAsk(ask);
12 ✔
842
    }
843

844
    // ---------------- Tier UPDATE loops ----------------
845

846
    /**
847
     * Per-tier inserted-triple tallies for one build or cycle. Counts the sum
848
     * of {@code (graphSize_after - graphSize_before)} across all iterations of
849
     * each tier's fixed-point INSERT loop — i.e. inserted *triples*, not
850
     * distinct subjects (a single RoleInstantiation insert writes 4–5 triples).
851
     *
852
     * <p>Used internally by the {@link #runIncrementalCycle structuralAdds}
853
     * boolean check (we only care whether any tier inserted at all).
854
     * Not what the log lines report: see {@link TierSubjectTotals} +
855
     * {@link #computeTierSubjectTotals} for the distinct-subject totals
856
     * surfaced to operators.
857
     */
858
    /**
859
     * Total triples inserted across every tier. Used both for the metrics gauge and
860
     * for the empty-build guard in {@link #runFullBuild}, so the two can never
861
     * disagree about what "this build produced nothing" means.
862
     */
863
    static long totalInserted(TierInsertedTriples c) {
864
        return (long) c.admin + c.alias + c.presetAttachment + c.presetAssignmentRef
144 ✔
865
                + c.attachment + c.maintainer + c.member + c.observer
866
                + c.subSpace + c.subSpacePrefix + c.maintainedResource
867
                + c.governingSpaceRef;
868
    }
869

870
    static final class TierInsertedTriples {
9 ✔
871
        int admin;
872
        int alias;
873
        int presetAttachment;
874
        int presetAssignmentRef;
875
        int attachment;
876
        int maintainer;
877
        int member;
878
        int observer;
879
        int subSpace;
880
        int subSpacePrefix;
881
        int maintainedResource;
882
        int governingSpaceRef;
883
    }
884

885
    /**
886
     * Snapshot of distinct-subject totals in a space-state graph at a moment
887
     * in time. Independent of which tier-loop added each subject.
888
     */
889
    record TierSubjectTotals(long adminRIs, long attachmentRAs, long nonAdminRIs) {}
36 ✔
890

891
    /**
892
     * Runs the five tier loops in order: admin → {@code gen:hasRole} attachment
893
     * validation → maintainer → member → observer. Each loop iterates a SPARQL
894
     * INSERT to fixed point (no new triples added). Returns per-tier counts.
895
     *
896
     * @param graph         target space-state graph
897
     * @param lastProcessed load-number horizon; use {@code -1} for full build
898
     */
899
    TierInsertedTriples runAllTierLoops(IRI graph, long lastProcessed) {
900
        TierInsertedTriples c = new TierInsertedTriples();
12 ✔
901
        c.admin = runTierLabeled("admin", graph, adminTierUpdate(graph, lastProcessed));
27 ✔
902
        // Alias admit runs after the admin closure has settled (both the authority
903
        // gate and the anti-hijack check read the admin set) and before attachment /
904
        // role tiers (their alias-aware admin lookups consume the npa:sameAsSpace edge
905
        // this pass emits). See issue #113.
906
        c.alias = runTierLabeled("alias", graph, aliasAdmitUpdate(graph, lastProcessed));
27 ✔
907
        // Sub-space admit runs after admin closure has settled (Mode A + Mode B both
908
        // need the admin set). Independent of role tiers — order between subspace
909
        // and attachment / maintainer / member / observer doesn't matter.
910
        c.subSpace = runTierLabeled("subspace", graph, subSpaceAdmitUpdate(graph, lastProcessed));
27 ✔
911
        // Maintained-resource admit also depends only on the admin closure. Single
912
        // Mode A: publisher must be admin of the maintaining space. No co-declaration
913
        // partner, no URL-prefix fallback.
914
        c.maintainedResource = runTierLabeled("maintained-resource", graph,
24 ✔
915
                maintainedResourceAdmitUpdate(graph, lastProcessed));
3 ✔
916
        // URL-prefix sub-space fallback runs after the explicit-declaration admit
917
        // pass commits so the per-child suppression check sees this cycle's fresh
918
        // validations. No load filter — depends on which Spaces exist, not on
919
        // delta-arrivals; the dedup FILTER NOT EXISTS prevents re-insertion.
920
        c.subSpacePrefix = runTierLabeled("subspace-prefix", graph,
21 ✔
921
                subSpacePrefixFallbackUpdate(graph));
3 ✔
922
        // Reflexive governing-space-ref edges (issue #130). Self-healing, no load filter;
923
        // runs after the maintained-resource admit so a maintained resource that is itself
924
        // a space already has its maintained governing edge by now (the two are independent
925
        // anyway — different subjects/objects). Order vs. other tiers doesn't matter.
926
        c.governingSpaceRef = runTierLabeled("governing-space-ref", graph,
21 ✔
927
                governingSpaceRefReflexiveUpdate(graph));
3 ✔
928
        // Preset-attachment runs immediately before the regular attachment tier so the
929
        // gen:RoleAssignment rows it materializes (from active, admin-authored preset
930
        // assignments) are picked up by the downstream non-admin tiers in the same pass,
931
        // exactly like directly-published attachments. See
932
        // doc/design-preset-role-materialization.md.
933
        c.presetAttachment = runTierLabeled("preset-attachment", graph,
24 ✔
934
                presetAttachmentValidationUpdate(graph, lastProcessed));
3 ✔
935
        // Ref-scoped preset-assignment listing stamp (issue #122). Display-only leaf —
936
        // independent of the role tiers and of structuralAdds; order doesn't matter.
937
        c.presetAssignmentRef = runTierLabeled("preset-assignment-ref", graph,
24 ✔
938
                presetAssignmentRefStampUpdate(graph, lastProcessed));
3 ✔
939
        c.attachment = runTierLabeled("attachment", graph,
24 ✔
940
                attachmentValidationUpdate(graph, lastProcessed));
3 ✔
941
        c.maintainer = runTierLabeled("maintainer", graph, nonAdminTierUpdate(graph, lastProcessed,
33 ✔
942
                GEN.MAINTAINER_ROLE, PUBLISHER_IS_ADMIN));
943
        // Member tier: admin OR maintainer publisher — split into two simpler updates
944
        // so the query planner doesn't struggle with the UNION.
945
        c.member = runTierLabeled("member(admin-pub)", graph, nonAdminTierUpdate(graph, lastProcessed,
33 ✔
946
                GEN.MEMBER_ROLE, PUBLISHER_IS_ADMIN));
947
        c.member += runTierLabeled("member(maint-pub)", graph, nonAdminTierUpdate(graph, lastProcessed,
42 ✔
948
                GEN.MEMBER_ROLE, publisherIsTieredRole(GEN.MAINTAINER_ROLE)));
3 ✔
949
        // Observer tier: self-evidence OR a downward grant from any higher tier.
950
        // ObserverRole is the default tier when a role definition omits an
951
        // explicit subclass (see "Role types" in design-space-repositories.md), so
952
        // most "X assigned Y this role" nanopubs land here. Restricting the tier
953
        // to PUBLISHER_IS_SELF would silently drop those grants. The four
954
        // sub-loops mirror the trust-state's downward-only chain: admin grants
955
        // anything; maintainers and members grant observer; everyone may
956
        // self-attest.
957
        c.observer = runTierLabeled("observer(admin-pub)", graph, nonAdminTierUpdate(graph, lastProcessed,
33 ✔
958
                GEN.OBSERVER_ROLE, PUBLISHER_IS_ADMIN));
959
        c.observer += runTierLabeled("observer(maint-pub)", graph, nonAdminTierUpdate(graph, lastProcessed,
42 ✔
960
                GEN.OBSERVER_ROLE, publisherIsTieredRole(GEN.MAINTAINER_ROLE)));
3 ✔
961
        c.observer += runTierLabeled("observer(member-pub)", graph, nonAdminTierUpdate(graph, lastProcessed,
42 ✔
962
                GEN.OBSERVER_ROLE, publisherIsTieredRole(GEN.MEMBER_ROLE)));
3 ✔
963
        c.observer += runTierLabeled("observer(self)", graph, nonAdminTierUpdate(graph, lastProcessed,
42 ✔
964
                GEN.OBSERVER_ROLE, PUBLISHER_IS_SELF));
965
        // Self-attestation by a not-yet-approved account (issue #195). Runs after the
966
        // approved pass; the two are disjoint by construction (a pending row only exists
967
        // for an agent with no approved row) and the tier's dedup covers the rest.
968
        c.observer += runTierLabeled("observer(self,pending)", graph, nonAdminTierUpdate(graph, lastProcessed,
45 ✔
969
                GEN.OBSERVER_ROLE, PUBLISHER_IS_SELF_PENDING, PENDING_ROLE_STAMP));
970
        return c;
6 ✔
971
    }
972

973
    /**
974
     * Builds a publisher constraint requiring the publisher to be a validated holder
975
     * of the given tier's role (maintainer or member) in the target space.
976
     * Owns its own AccountState resolution so ?publisher is bound through the
977
     * targeted (pkh → agent) lookup rather than enumerated.
978
     */
979
    private static String publisherIsTieredRole(IRI tierClass) {
980
        // Re-keyed on the assignment's ref (alias → canonical already resolved by the
981
        // attachment tier). Relies on materialized non-admin RIs carrying their role
982
        // property (npa:regularProperty / npa:inverseProperty) — supplied by the
983
        // enrichment in nonAdminTierUpdate; without it this constraint matched nothing.
984
        return """
24 ✔
985
                ?acct a npa:AccountState ;
986
                      npa:pubkey ?pkh ;
987
                      npa:agent  ?publisher .
988
                ?tierRI a gen:RoleInstantiation ;
989
                        npa:forSpaceRef ?spaceRef ;
990
                        npa:forAgent ?publisher .
991
                ?rdT a npa:RoleDeclaration ;
992
                     npa:hasRoleType <%1$s> .
993
                { ?tierRI npa:regularProperty ?predT . ?rdT gen:hasRegularProperty ?predT . }
994
                UNION
995
                { ?tierRI npa:inverseProperty ?predT . ?rdT gen:hasInverseProperty ?predT . }
996
                """.formatted(tierClass);
3 ✔
997
    }
998

999
    // ---------------- Role revocation / detachment (issue #129) ----------------
1000

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

1012
    /** Inner {@code GRAPH} block matching a revoker who is a validated admin of {@code ?spaceRef}. */
1013
    private static String revokerAdminGraphBlock(IRI graph) {
1014
        return String.format("""
27 ✔
1015
                GRAPH <%1$s> {
1016
                  ?revAcct a npa:AccountState ; npa:pubkey ?revPkh ; npa:agent ?revAgent .
1017
                  ?revRI a gen:RoleInstantiation ;
1018
                         npa:forSpaceRef ?spaceRef ;
1019
                         npa:inverseProperty gen:hasAdmin ;
1020
                         npa:forAgent ?revAgent .
1021
                }""", graph);
1022
    }
1023

1024
    /** Inner {@code GRAPH} block matching a revoker who holds {@code tier} in {@code ?spaceRef}. */
1025
    private static String revokerTierGraphBlock(IRI graph, IRI tier) {
1026
        return String.format("""
39 ✔
1027
                GRAPH <%1$s> {
1028
                  ?revAcct a npa:AccountState ; npa:pubkey ?revPkh ; npa:agent ?revAgent .
1029
                  ?revRI a gen:RoleInstantiation ;
1030
                         npa:forSpaceRef ?spaceRef ;
1031
                         npa:forAgent ?revAgent ;
1032
                         npa:hasRoleType <%2$s> .
1033
                }""", graph, tier);
1034
    }
1035

1036
    /** Inner {@code GRAPH} block matching a self-revoke: the revoker's key belongs to {@code ?agent}. */
1037
    private static String revokerSelfGraphBlock(IRI graph) {
1038
        return String.format("""
27 ✔
1039
                GRAPH <%1$s> {
1040
                  ?revAcct a npa:AccountState ; npa:pubkey ?revPkh ; npa:agent ?agent .
1041
                }""", graph);
1042
    }
1043

1044
    /**
1045
     * Authorization arms for an instantiation revocation targeting a <em>compile-time</em>
1046
     * tier — issue #129's matrix, the single arm builder used by BOTH the inline suppression
1047
     * filter and the (per-tier-scoped) displacement DELETE, so the two paths can never
1048
     * authorize different revokers and flip-flop a row. UNION of only the arms the matrix
1049
     * permits for {@code targetTier}: admin of {@code ?spaceRef} (revokes any non-admin); a
1050
     * maintainer (member/observer targets); a member (observer target); plus the assignee
1051
     * itself (self-leave, any tier). A revoker must hold a tier strictly higher than the
1052
     * target. Compile-time selection (no runtime {@code ?tier} variable) deliberately avoids
1053
     * the SPARQL pitfall where a {@code FILTER} inside a {@code UNION} branch cannot see a
1054
     * {@code ?tier} bound in the enclosing group.
1055
     */
1056
    private static String revocationAuthorityArmsForTier(IRI graph, IRI targetTier) {
1057
        List<String> arms = new ArrayList<>();
12 ✔
1058
        arms.add("{ " + revokerAdminGraphBlock(graph) + " }");
18 ✔
1059
        if (GEN.MEMBER_ROLE.equals(targetTier) || GEN.OBSERVER_ROLE.equals(targetTier)) {
24 ✔
1060
            arms.add("{ " + revokerTierGraphBlock(graph, GEN.MAINTAINER_ROLE) + " }");
21 ✔
1061
        }
1062
        if (GEN.OBSERVER_ROLE.equals(targetTier)) {
12 ✔
1063
            arms.add("{ " + revokerTierGraphBlock(graph, GEN.MEMBER_ROLE) + " }");
21 ✔
1064
        }
1065
        arms.add("{ " + revokerSelfGraphBlock(graph) + " }");
18 ✔
1066
        return String.join("\nUNION\n", arms);
12 ✔
1067
    }
1068

1069
    /**
1070
     * Inline suppression filter for {@code nonAdminTierUpdate}: rejects a candidate
1071
     * instantiation ({@code ?ri}, created {@code ?candCreated}) whose {@code (space, agent,
1072
     * role)} key has a newer authorized {@code npa:RoleRevocation}, using
1073
     * {@link #revocationAuthorityArmsForTier} for {@code targetTier} (the loop tier) — the same
1074
     * builder the displacement DELETE uses, so suppression and re-materialization always agree.
1075
     * The revocation's named space is matched against any IRI denoting {@code ?spaceRef}
1076
     * (canonical or validated {@code owl:sameAs} alias, issue #113), so an alias-named
1077
     * revocation is not a silent no-op. Latest-wins by {@code dct:created} ({@link #EPOCH_DT}
1078
     * fallback) with an {@code STR()} subject tiebreak. Not wrapped in {@code invalidationFilter}:
1079
     * per issue #129 the only un-revoke path is a newer positive re-assignment.
1080
     */
1081
    private static String nonAdminRevocationSuppressionFilter(IRI graph, IRI targetTier) {
1082
        return String.format("""
51 ✔
1083
                FILTER NOT EXISTS {
1084
                  { GRAPH <%2$s> { ?spaceRef npa:spaceIri ?revSpace . } }
1085
                  UNION
1086
                  { GRAPH <%1$s> { ?revSpace npa:sameAsSpace ?spaceRef . } }
1087
                  GRAPH <%2$s> {
1088
                    ?rev a npa:RoleRevocation ;
1089
                         npa:forSpace    ?revSpace ;
1090
                         npa:forAgent    ?agent ;
1091
                         npa:revokedRole ?role ;
1092
                         npa:pubkeyHash  ?revPkh .
1093
                    OPTIONAL { ?rev <http://purl.org/dc/terms/created> ?revCreatedRaw . }
1094
                  }
1095
                  BIND(COALESCE(?revCreatedRaw, %4$s) AS ?revCreated)
1096
                  FILTER (?revCreated > ?candCreated
1097
                          || (?revCreated = ?candCreated && STR(?rev) > STR(?ri)))
1098
                  { %3$s }
1099
                }""", graph, SpacesVocab.SPACES_GRAPH,
1100
                revocationAuthorityArmsForTier(graph, targetTier), EPOCH_DT);
18 ✔
1101
    }
1102

1103
    /**
1104
     * Inline suppression filter for {@code adminTierUpdate}: rejects an admin instantiation
1105
     * ({@code ?ri}, created {@code ?candCreated}) whose {@code (ref, agent)} key has a newer
1106
     * authorized admin {@code npa:RoleRevocation} ({@code revokedRole = gen:AdminRole}) —
1107
     * authorized by an admin of the ref (admins revoke admins) or by the agent itself
1108
     * (self-leave). <b>Root admins are exempt</b> (issue #129/#110): a nested
1109
     * {@code FILTER NOT EXISTS} on {@code npa:hasRootAdmin} makes any revocation against a
1110
     * root admin structurally inert, overriding self-leave. {@code gen:AdminRole} resolves
1111
     * via the {@code gen:} prefix the admin-tier template declares.
1112
     */
1113
    private static String adminRevocationSuppressionFilter(IRI graph) {
1114
        return String.format("""
48 ✔
1115
                FILTER NOT EXISTS {
1116
                  FILTER NOT EXISTS { GRAPH <%2$s> {
1117
                    ?rootDef a npa:SpaceDefinition ;
1118
                             npa:forSpaceRef  ?spaceRef ;
1119
                             npa:hasRootAdmin ?agent .
1120
                  } }
1121
                  { GRAPH <%2$s> { ?spaceRef npa:spaceIri ?revSpace . } }
1122
                  UNION
1123
                  { GRAPH <%1$s> { ?revSpace npa:sameAsSpace ?spaceRef . } }
1124
                  GRAPH <%2$s> {
1125
                    ?rev a npa:RoleRevocation ;
1126
                         npa:forSpace    ?revSpace ;
1127
                         npa:forAgent    ?agent ;
1128
                         npa:revokedRole gen:AdminRole ;
1129
                         npa:pubkeyHash  ?revPkh .
1130
                    OPTIONAL { ?rev <http://purl.org/dc/terms/created> ?revCreatedRaw . }
1131
                  }
1132
                  BIND(COALESCE(?revCreatedRaw, %4$s) AS ?revCreated)
1133
                  FILTER (?revCreated > ?candCreated
1134
                          || (?revCreated = ?candCreated && STR(?rev) > STR(?ri)))
1135
                  { %3$s }
1136
                }""", graph, SpacesVocab.SPACES_GRAPH,
1137
                "{ " + revokerAdminGraphBlock(graph) + " }\nUNION\n{ "
6 ✔
1138
                        + revokerSelfGraphBlock(graph) + " }",
21 ✔
1139
                EPOCH_DT);
1140
    }
1141

1142
    /**
1143
     * Inline suppression filter for the attachment tiers ({@code attachmentValidationUpdate}
1144
     * and {@code presetAttachmentValidationUpdate}): rejects a {@code (targetRef, role)}
1145
     * attachment whose effective timestamp ({@code ?<createdVar>}) is out-ranked by a newer
1146
     * admin-authored {@code npa:RoleDetachment} (issue #129). Authority = admin of
1147
     * {@code ?targetRef} (matching who may attach). Non-sticky latest-wins: a newer
1148
     * attachment / preset assignment naturally re-attaches because its timestamp beats the
1149
     * detachment. The detachment's named space is matched against any IRI denoting
1150
     * {@code ?targetRef} (canonical or {@code owl:sameAs} alias).
1151
     *
1152
     * @param createdVar     bare name of the attachment's effective-created variable
1153
     * @param attachSubjVar  bare name of the attachment subject variable (for the STR tiebreak)
1154
     */
1155
    private static String roleDetachmentSuppressionFilter(IRI graph, String createdVar, String attachSubjVar) {
1156
        return String.format("""
75 ✔
1157
                FILTER NOT EXISTS {
1158
                  { GRAPH <%2$s> { ?targetRef npa:spaceIri ?detSpace . } }
1159
                  UNION
1160
                  { GRAPH <%1$s> { ?detSpace npa:sameAsSpace ?targetRef . } }
1161
                  GRAPH <%2$s> {
1162
                    ?det a npa:RoleDetachment ;
1163
                         npa:forSpace    ?detSpace ;
1164
                         npa:revokedRole ?role ;
1165
                         npa:pubkeyHash  ?detPkh .
1166
                    OPTIONAL { ?det <http://purl.org/dc/terms/created> ?detCreatedRaw . }
1167
                  }
1168
                  BIND(COALESCE(?detCreatedRaw, %5$s) AS ?detCreated)
1169
                  FILTER (?detCreated > ?%3$s
1170
                          || (?detCreated = ?%3$s && STR(?det) > STR(?%4$s)))
1171
                  GRAPH <%1$s> {
1172
                    ?detAcct a npa:AccountState ; npa:pubkey ?detPkh ; npa:agent ?detAgent .
1173
                    ?detAdminRI a gen:RoleInstantiation ;
1174
                                npa:forSpaceRef ?targetRef ;
1175
                                npa:inverseProperty gen:hasAdmin ;
1176
                                npa:forAgent ?detAgent .
1177
                  }
1178
                }""", graph, SpacesVocab.SPACES_GRAPH, createdVar, attachSubjVar, EPOCH_DT);
1179
    }
1180

1181
    /** Wraps {@link #runTierLoop} with tier-name context for logs/exceptions. */
1182
    private int runTierLabeled(String tier, IRI graph, String sparqlUpdate) {
1183
        try {
1184
            return runTierLoop(graph, sparqlUpdate);
15 ✔
1185
        } catch (RuntimeException ex) {
×
1186
            logger.error("AuthorityResolver: tier={} failed with SPARQL UPDATE:\n{}\n", tier, sparqlUpdate, ex);
×
1187
            throw ex;
×
1188
        }
1189
    }
1190

1191
    /**
1192
     * Runs a single tier's INSERT to fixed point. Counts rows by probing
1193
     * graph size before/after each INSERT; stops when the size doesn't change.
1194
     *
1195
     * @return total number of triples inserted by this tier across all iterations
1196
     */
1197
    int runTierLoop(IRI graph, String sparqlUpdate) {
1198
        int total = 0;
6 ✔
1199
        long before = graphSize(graph);
12 ✔
1200
        while (true) {
1201
            // Note: no explicit transaction wrapping here. In tests we observed that
1202
            // HTTPRepository's RDF4J-transaction protocol silently no-op'd cross-graph
1203
            // SPARQL UPDATEs with UNION sub-patterns inside conn.begin()/commit(),
1204
            // while the same UPDATE POSTed directly to /statements applied correctly.
1205
            // A bare prepareUpdate().execute() takes the direct /statements path and
1206
            // runs the UPDATE atomically per SPARQL 1.1 semantics — which is all we
1207
            // need; there's nothing else to commit atomically alongside the UPDATE.
1208
            try (RepositoryConnection conn = TripleStore.get().getRepoConnection(SPACES_REPO)) {
12 ✔
1209
                Update update = conn.prepareUpdate(QueryLanguage.SPARQL, sparqlUpdate);
15 ✔
1210
                update.setMaxExecutionTime(SERVER_TIME_LIMIT_SECONDS);
9 ✔
1211
                update.execute();
6 ✔
1212
            }
1213
            long after = graphSize(graph);
12 ✔
1214
            long added = after - before;
12 ✔
1215
            if (added <= 0) break;
15 ✔
1216
            total += added;
18 ✔
1217
            before = after;
6 ✔
1218
        }
3 ✔
1219
        return total;
6 ✔
1220
    }
1221

1222
    private long graphSize(IRI graph) {
1223
        try (RepositoryConnection conn = TripleStore.get().getRepoConnection(SPACES_REPO)) {
12 ✔
1224
            return conn.size(graph);
33 ✔
1225
        }
1226
    }
1227

1228
    /**
1229
     * Distinct-subject totals in the given space-state graph, broken down by
1230
     * RoleInstantiation kind (admin-pinned vs not) and RoleAssignment.
1231
     * Three SELECT-COUNT queries — cheap, called once per build/cycle for
1232
     * the user-facing log line. Returns zeros on failure (logged) so a flaky
1233
     * count read can't wedge the cycle.
1234
     */
1235
    TierSubjectTotals computeTierSubjectTotals(IRI graph) {
1236
        long adminRIs       = countDistinctSubjects(graph, """
18 ✔
1237
                ?ri a gen:RoleInstantiation ; npa:inverseProperty gen:hasAdmin .
1238
                """, "ri");
1239
        long attachmentRAs  = countDistinctSubjects(graph, """
18 ✔
1240
                ?ra a gen:RoleAssignment .
1241
                """, "ra");
1242
        long nonAdminRIs    = countDistinctSubjects(graph, """
18 ✔
1243
                ?ri a gen:RoleInstantiation .
1244
                FILTER NOT EXISTS { ?ri npa:inverseProperty gen:hasAdmin }
1245
                """, "ri");
1246
        return new TierSubjectTotals(adminRIs, attachmentRAs, nonAdminRIs);
21 ✔
1247
    }
1248

1249
    private long countDistinctSubjects(IRI graph, String wherePattern, String varName) {
1250
        String query = String.format("""
75 ✔
1251
                PREFIX npa: <%1$s>
1252
                PREFIX gen: <%2$s>
1253
                SELECT (COUNT(DISTINCT ?%3$s) AS ?n) WHERE {
1254
                  GRAPH <%4$s> {
1255
                    %5$s
1256
                  }
1257
                }
1258
                """, NPA.NAMESPACE, GEN.NAMESPACE, varName, graph, wherePattern);
1259
        try (RepositoryConnection conn = TripleStore.get().getRepoConnection(SPACES_REPO);
12 ✔
1260
             TupleQueryResult r = conn.prepareTupleQuery(QueryLanguage.SPARQL, query).evaluate()) {
18 ✔
1261
            if (!r.hasNext()) return 0;
9 !
1262
            return Long.parseLong(r.next().getBinding("n").getValue().stringValue());
33 ✔
1263
        } catch (Exception ex) {
3 !
1264
            logger.warn("AuthorityResolver: countDistinctSubjects on {} failed: {}",
15 ✔
1265
                    graph, ex.toString());
3 ✔
1266
            return 0;
6 ✔
1267
        }
1268
    }
1269

1270
    // ---------------- SPARQL templates ----------------
1271

1272
    /**
1273
     * Reusable invalidation filter on a bound nanopub-IRI variable. Pass the bare
1274
     * variable name (no leading {@code ?}); e.g. {@code invalidationFilter("np")}
1275
     * produces an outer-scoped {@code FILTER NOT EXISTS { GRAPH npa:graph
1276
     * { ?_inv_np npx:invalidates ?np . } }}.
1277
     *
1278
     * <p>Joins on the raw {@code npx:invalidates} triple in {@code npa:graph},
1279
     * which {@link com.knowledgepixels.query.NanopubLoader} writes into the
1280
     * spaces repo from two complementary directions, making the filter symmetric
1281
     * in load order:
1282
     * <ul>
1283
     *   <li>At the invalidator's own load: the loader's space-repo trigger fires
1284
     *       whenever the nanopub has either its own space-relevant extractions
1285
     *       OR an {@code npx:invalidates}/{@code npx:retracts}/{@code npx:supersedes}
1286
     *       triple, so a pure-retraction nanopub still lands its raw triple plus
1287
     *       {@code npa:hasLoadNumber} stamp in {@code npa:graph}.</li>
1288
     *   <li>At the invalidated target's load (when the invalidator landed
1289
     *       earlier): {@code NanopubLoader.getInvalidatingStatements} reads the
1290
     *       triple back from the meta repo and mirrors it into the target's own
1291
     *       write to the spaces repo.</li>
1292
     * </ul>
1293
     *
1294
     * <p>The earlier shape joined on a structured {@code npa:Invalidation} entry
1295
     * in {@code npa:spacesGraph} that was only emitted on the invalidator's side
1296
     * AND only when the invalidated target's meta had already loaded, leaving a
1297
     * window where a superseding nanopub loaded before its target produced no
1298
     * entry and the stale row was never filtered out (see also the matching
1299
     * change in the tier-specific {@code *InvalidationCheckWhere}/{@code
1300
     * *InvalidationDelete} templates below).
1301
     *
1302
     * <p>Important: this filter must be placed OUTSIDE the surrounding
1303
     * {@code GRAPH npa:spacesGraph { ... }} block, not nested inside it. When
1304
     * nested, RDF4J's planner couples the FILTER NOT EXISTS evaluation into the
1305
     * join order (per-row scan multiplied by the candidate set), which we
1306
     * measured turning a 39ms query into a 60s+ timeout on the live observer-tier
1307
     * data. Outside the GRAPH block, the planner defers the filter until
1308
     * {@code ?np}/{@code ?rdNp} are bound and does a targeted index lookup.
1309
     *
1310
     * <p>Variable names must match {@code [A-Za-z0-9_]+} per SPARQL grammar —
1311
     * embedding a {@code ?} inside {@code ?_inv_?np} would yield a parse error.
1312
     */
1313
    private static String invalidationFilter(String bareVarName) {
1314
        return "FILTER NOT EXISTS { GRAPH <" + NPA.GRAPH + "> {"
30 ✔
1315
                + " ?_inv_" + bareVarName
1316
                + " <" + NPX.INVALIDATES + "> ?" + bareVarName + " . "
1317
                + samePublisherClause("_inv_" + bareVarName, bareVarName)
6 ✔
1318
                + " } }";
1319
    }
1320

1321
    /**
1322
     * SPARQL triple pair (placed inside a {@code GRAPH npa:graph { ... }} block)
1323
     * requiring the invalidating nanopub and its target to share a signing public
1324
     * key — the self-retraction authority gate for issue #112. Without it, the
1325
     * materializer honors {@code npx:invalidates}/{@code retracts}/{@code supersedes}
1326
     * from <em>any</em> validly-signed nanopub, so any agent can erase another
1327
     * space's materialized state (griefing/DoS of the view — fail-closed, no
1328
     * privilege escalation, but real). Additions are already admin-gated; this is
1329
     * the symmetric gate on removals.
1330
     *
1331
     * <p>Both {@code npa:hasValidSignatureForPublicKeyHash} triples live in
1332
     * {@code npa:graph} of the spaces repo: the target via its own space-load, the
1333
     * invalidator via the symmetric retractor propagation in
1334
     * {@link com.knowledgepixels.query.NanopubLoader} (forward {@code
1335
     * loadInvalidateStatements} + reverse {@code loadInvalidatorIntoSpacesRepo}),
1336
     * so the join is populated regardless of load order.
1337
     *
1338
     * <p>"Same pubkey" is intentionally stricter than "same agent": a retraction
1339
     * signed by a different key the author owns (key rotation) is not honored, and
1340
     * cross-admin supersession is out of scope here (would need an admin-authority
1341
     * arm). The pubkey-bridge variable is suffixed with {@code targetVar} so two
1342
     * filters in one query (e.g. on {@code ?np} and {@code ?rdNp}) don't collide.
1343
     *
1344
     * @param invVar    invalidator nanopub variable name (no leading {@code ?})
1345
     * @param targetVar invalidated-target nanopub variable name (no leading {@code ?})
1346
     */
1347
    private static String samePublisherClause(String invVar, String targetVar) {
1348
        String pk = "?_invpk_" + targetVar;
9 ✔
1349
        return "?" + invVar + " <" + NPA.HAS_VALID_SIGNATURE_FOR_PUBLIC_KEY_HASH + "> " + pk + " . "
30 ✔
1350
                + "?" + targetVar + " <" + NPA.HAS_VALID_SIGNATURE_FOR_PUBLIC_KEY_HASH + "> " + pk + " .";
1351
    }
1352

1353
    /**
1354
     * Admin tier: seed from {@code npadef:...hasRootAdmin} (trusted by construction)
1355
     * plus closed-over admin grants; insert any {@code gen:RoleInstantiation} with
1356
     * {@code npa:inverseProperty gen:hasAdmin} whose publisher (resolved via mirrored
1357
     * trust-approved AccountState) is already in the admin set.
1358
     *
1359
     * <p>The seed is gated by {@link #spaceRefAliveFilter} (not the per-nanopub
1360
     * {@code invalidationFilter("defNp")}): the {@code hasRootAdmin} seed is anchored
1361
     * to the root NPID, which is the immutable space-ref identity, so superseding the
1362
     * root <em>nanopub</em> with a continuation revision must not strip the seed —
1363
     * only retracting every definition of the ref removes it. See issue #110.
1364
     */
1365
    static String adminTierUpdate(IRI graph, long lastProcessed) {
1366
        // Order tuned for RDF4J's evaluator:
1367
        //   1. Anchor on the small (seed UNION closed-over) set to bind ?publisher
1368
        //      and ?space cheaply.
1369
        //   2. Resolve ?pkh from the mirrored AccountState row (?publisher bound).
1370
        //   3. Probe instantiations using the now-bound (?space, ?pkh) — targeted
1371
        //      lookup, not a full RoleInstantiation scan.
1372
        //   4. Load-number filter on bound ?np.
1373
        //   5. Dedup at the end.
1374
        // Authority is keyed on the space *ref* (npa:forSpaceRef), not the bare Space
1375
        // IRI: two refs that share an IRI but have different roots are independent
1376
        // domains (see doc/design-spaceref-isolation.md). The instantiation evidence in
1377
        // the extraction graph is IRI-keyed (a gen:hasAdmin nanopub names the bare IRI),
1378
        // so we project it per-ref by joining each instantiation naming ?space to the
1379
        // admin rows of every ref of ?space whose admin set contains the publisher. The
1380
        // inserted subject is minted per (?ri, ?spaceRef) so one instantiation validating
1381
        // into N refs yields N distinct rows. TRANSITIONAL-DUAL-EMIT (Phase 4: remove):
1382
        // forSpace is still emitted alongside forSpaceRef so the not-yet-migrated
1383
        // downstream tiers / pre-ref read queries keep functioning on a mixed-version
1384
        // fleet; it is dropped once everything keys on forSpaceRef.
1385
        return """
69 ✔
1386
                PREFIX npa:  <%1$s>
1387
                PREFIX gen:  <%2$s>
1388
                INSERT { GRAPH <%3$s> {
1389
                  ?sri a gen:RoleInstantiation ;
1390
                       npa:forSpaceRef ?spaceRef ;
1391
                       npa:forSpace ?space ;
1392
                       npa:inverseProperty gen:hasAdmin ;
1393
                       # Stamp the admin tier so consumers read tier uniformly across all
1394
                       # RoleInstantiations (?ri npa:hasRoleType ?tier) with no admin
1395
                       # special-case — matching the non-admin path (issue #125, #127).
1396
                       npa:hasRoleType gen:AdminRole ;
1397
                       npa:forAgent ?agent ;
1398
                       npa:viaNanopub ?np .
1399
                } }
1400
                WHERE {
1401
                  # 1. Anchor: who is already an admin of which space ref?
1402
                  {
1403
                    # Seed branch: root-admin of a space ref that is still alive
1404
                    # (has at least one non-invalidated definition). NOT filtered on
1405
                    # ?def's own invalidation — superseding the root nanopub with a
1406
                    # continuation revision must keep the seed; only a fully-retracted
1407
                    # ref drops it (issue #110).
1408
                    GRAPH <%4$s> {
1409
                      ?def a npa:SpaceDefinition ;
1410
                           npa:forSpaceRef  ?spaceRef ;
1411
                           npa:hasRootAdmin ?publisher .
1412
                      ?spaceRef npa:spaceIri ?space .
1413
                    }
1414
                    %7$s
1415
                  }
1416
                  UNION
1417
                  {
1418
                    # Closed-over branch: an existing admin of this ref. Recurse on the
1419
                    # ref, then resolve its bare IRI to probe the IRI-keyed instantiation.
1420
                    GRAPH <%3$s> {
1421
                      ?prev a gen:RoleInstantiation ;
1422
                            npa:forSpaceRef     ?spaceRef ;
1423
                            npa:inverseProperty gen:hasAdmin ;
1424
                            npa:forAgent        ?publisher .
1425
                    }
1426
                    GRAPH <%4$s> {
1427
                      ?spaceRef npa:spaceIri ?space .
1428
                    }
1429
                  }
1430
                  # 2. Mirror: resolve ?publisher → ?pkh via the trust-approved row.
1431
                  GRAPH <%3$s> {
1432
                    ?acct a npa:AccountState ;
1433
                          npa:agent  ?publisher ;
1434
                          npa:pubkey ?pkh .
1435
                  }
1436
                  # 3. Targeted instantiation lookup by space + pubkey (IRI-keyed).
1437
                  GRAPH <%4$s> {
1438
                    ?ri a gen:RoleInstantiation ;
1439
                        npa:forSpace        ?space ;
1440
                        npa:inverseProperty gen:hasAdmin ;
1441
                        npa:forAgent        ?agent ;
1442
                        npa:pubkeyHash      ?pkh ;
1443
                        npa:viaNanopub      ?np .
1444
                    # Candidate grant timestamp for the admin-revocation latest-wins (#129).
1445
                    OPTIONAL { ?ri <http://purl.org/dc/terms/created> ?candCreatedRaw . }
1446
                  }
1447
                  BIND(COALESCE(?candCreatedRaw, %9$s) AS ?candCreated)
1448
                  # 3a. Mint the per-ref state subject: (?ri, ?spaceRef) → ?sri.
1449
                  BIND(IRI(CONCAT(STR(?ri), "__", ENCODE_FOR_URI(STR(?spaceRef)))) AS ?sri)
1450
                  %6$s
1451
                  # 4. Load-number filter on bound ?np.
1452
                  GRAPH <%8$s> {
1453
                    ?np npa:hasLoadNumber ?ln .
1454
                    FILTER (?ln > %5$d)
1455
                  }
1456
                  # 4a. Admin-revocation latest-wins (issue #129): suppress if a newer
1457
                  #     authorized admin revocation shadows (ref, agent) — unless ?agent is a
1458
                  #     root admin (constitutional exemption, overrides self-leave).
1459
                  %10$s
1460
                  # 5. Dedup last — keyed on (ref, agent).
1461
                  FILTER NOT EXISTS { GRAPH <%3$s> {
1462
                    ?existing a gen:RoleInstantiation ;
1463
                              npa:forSpaceRef ?spaceRef ;
1464
                              npa:forAgent ?agent ;
1465
                              npa:inverseProperty gen:hasAdmin .
1466
                  } }
1467
                }
1468
                """.formatted(
3 ✔
1469
                NPA.NAMESPACE,
1470
                GEN.NAMESPACE,
1471
                graph,
1472
                SpacesVocab.SPACES_GRAPH,
1473
                lastProcessed,
15 ✔
1474
                invalidationFilter("np"),
12 ✔
1475
                spaceRefAliveFilter(),
39 ✔
1476
                NPA.GRAPH,
1477
                EPOCH_DT,
1478
                adminRevocationSuppressionFilter(graph));
6 ✔
1479
    }
1480

1481
    /**
1482
     * Seed-survival filter for the admin tier (issue #110). The {@code hasRootAdmin}
1483
     * seed is anchored to the root NPID, which is the immutable space-ref identity, so
1484
     * it must survive supersession of the root <em>nanopub</em> by a continuation
1485
     * revision (a later definition re-roots to the same ref via
1486
     * {@code gen:hasRootDefinition} and so carries no {@code hasRootAdmin} of its own).
1487
     * The previous {@code invalidationFilter("defNp")} dropped the seed the moment the
1488
     * root revision was superseded, leaving the whole admin closure — and everything
1489
     * cascading from it — unmaterialized for any space whose definition had ever been
1490
     * updated.
1491
     *
1492
     * <p>Expressed positively: the seed survives iff the space ref still has at least
1493
     * one non-invalidated {@link SpacesVocab#SPACE_DEFINITION}. A fully-retracted ref
1494
     * (every definition invalidated) has no live definition, so the {@code FILTER
1495
     * EXISTS} fails and the seed correctly disappears. Anchored on the already-bound
1496
     * {@code ?spaceRef}, so it's a targeted lookup over that ref's (few) definitions.
1497
     */
1498
    private static String spaceRefAliveFilter() {
1499
        return """
33 ✔
1500
                FILTER EXISTS {
1501
                  GRAPH <%1$s> {
1502
                    ?liveDef a npa:SpaceDefinition ;
1503
                             npa:forSpaceRef ?spaceRef ;
1504
                             npa:viaNanopub  ?liveNp .
1505
                  }
1506
                  %2$s
1507
                }
1508
                """.formatted(SpacesVocab.SPACES_GRAPH, invalidationFilter("liveNp"));
9 ✔
1509
    }
1510

1511
    /**
1512
     * {@code gen:hasRole} attachment validation: an attachment is validated iff its
1513
     * publisher is already a validated admin of the target space. Adds
1514
     * {@code gen:RoleAssignment} rows to the space-state graph.
1515
     */
1516
    static String attachmentValidationUpdate(IRI graph, long lastProcessed) {
1517
        // Ref-keyed (see doc/design-spaceref-isolation.md). The attachment names a bare
1518
        // Space IRI; it is validated per-ref for every ref of that IRI whose admin set
1519
        // contains the publisher (direct), or — when the named IRI is an owl:sameAs alias
1520
        // — for the canonical ref it maps to (issue #113). ?targetRef is the ref the
1521
        // RoleAssignment attaches to; the inserted subject is minted per (?ra, ?targetRef)
1522
        // so one attachment validating into N refs yields N distinct rows.
1523
        // TRANSITIONAL-DUAL-EMIT (Phase 4: remove): forSpace (the attached IRI, possibly an
1524
        // alias) is kept so the non-admin tier can probe the IRI-keyed instantiations
1525
        // naming it, and so pre-ref read queries keep functioning on a mixed-version fleet.
1526
        return """
69 ✔
1527
                PREFIX npa:  <%1$s>
1528
                PREFIX gen:  <%2$s>
1529
                INSERT { GRAPH <%3$s> {
1530
                  ?ra2 a gen:RoleAssignment ;
1531
                       npa:forSpaceRef ?targetRef ;
1532
                       npa:forSpace ?space ;
1533
                       gen:hasRole  ?role ;
1534
                       npa:viaNanopub ?np .
1535
                } }
1536
                WHERE {
1537
                  GRAPH <%4$s> {
1538
                    ?ra a gen:RoleAssignment ;
1539
                        npa:forSpace ?space ;
1540
                        gen:hasRole  ?role ;
1541
                        npa:pubkeyHash ?pkh ;
1542
                        npa:viaNanopub ?np .
1543
                    # Attachment timestamp for the detachment latest-wins (issue #129).
1544
                    OPTIONAL { ?ra <http://purl.org/dc/terms/created> ?attCreatedRaw . }
1545
                  }
1546
                  BIND(COALESCE(?attCreatedRaw, %8$s) AS ?attCreated)
1547
                  GRAPH <%7$s> {
1548
                    ?np npa:hasLoadNumber ?ln .
1549
                    FILTER (?ln > %5$d)
1550
                  }
1551
                  GRAPH <%3$s> {
1552
                    ?acct a npa:AccountState ;
1553
                          npa:agent  ?publisher ;
1554
                          npa:pubkey ?pkh .
1555
                  }
1556
                  # Per-ref admin gate. ?targetRef = a ref of ?space the publisher admins
1557
                  # (direct), or the canonical ref ?space is an owl:sameAs alias of.
1558
                  {
1559
                    GRAPH <%4$s> { ?targetRef npa:spaceIri ?space . }
1560
                    GRAPH <%3$s> {
1561
                      ?adminRI a gen:RoleInstantiation ;
1562
                               npa:forSpaceRef ?targetRef ;
1563
                               npa:inverseProperty gen:hasAdmin ;
1564
                               npa:forAgent ?publisher .
1565
                    }
1566
                  }
1567
                  UNION
1568
                  {
1569
                    GRAPH <%3$s> {
1570
                      ?space npa:sameAsSpace ?targetRef .
1571
                      ?adminRI a gen:RoleInstantiation ;
1572
                               npa:forSpaceRef ?targetRef ;
1573
                               npa:inverseProperty gen:hasAdmin ;
1574
                               npa:forAgent ?publisher .
1575
                    }
1576
                  }
1577
                  BIND(IRI(CONCAT(STR(?ra), "__", ENCODE_FOR_URI(STR(?targetRef)))) AS ?ra2)
1578
                  %6$s
1579
                  # Detachment latest-wins (issue #129): suppress if a newer admin-authored
1580
                  # gen:detachedRole out-ranks this (ref, role) attachment.
1581
                  %9$s
1582
                  FILTER NOT EXISTS { GRAPH <%3$s> {
1583
                    ?existing a gen:RoleAssignment ;
1584
                              npa:forSpaceRef ?targetRef ;
1585
                              gen:hasRole  ?role .
1586
                  } }
1587
                }
1588
                """.formatted(
3 ✔
1589
                NPA.NAMESPACE,
1590
                GEN.NAMESPACE,
1591
                graph,
1592
                SpacesVocab.SPACES_GRAPH,
1593
                lastProcessed,
15 ✔
1594
                invalidationFilter("np"),
45 ✔
1595
                NPA.GRAPH,
1596
                EPOCH_DT,
1597
                roleDetachmentSuppressionFilter(graph, "attCreated", "ra"));
6 ✔
1598
    }
1599

1600
    /**
1601
     * Preset-bundled role materialization (Nanodash issue #302). For each active,
1602
     * admin-authored {@code gen:PresetAssignment} targeting a {@code gen:Space}, inserts
1603
     * one {@code gen:RoleAssignment} per role the preset bundles — exactly as if
1604
     * {@code <space> gen:hasRole <role>} had been published by the assignment's publisher.
1605
     * The materialized rows carry {@code npa:derivedFromPreset} (the assignment nanopub)
1606
     * so the deactivation delete and read-side marking can scope to them without touching
1607
     * directly-published attachments. See {@code doc/design-preset-role-materialization.md}.
1608
     *
1609
     * <p>Activation is resolved by an <b>authorization-scoped latest-wins</b> over the
1610
     * {@code (preset, resource)} pair, NOT {@code npx:invalidates} (§3): the candidate set
1611
     * for the {@code MAX(dct:created)} comparison is restricted to assignments whose
1612
     * publisher is also a validated admin of the target ref, so an unauthorized key's newer
1613
     * assignment cannot shadow an admin's activation (the #113-class anti-hijack rule).
1614
     */
1615
    static String presetAttachmentValidationUpdate(IRI graph, long lastProcessed) {
1616
        // Ref-keyed like attachmentValidationUpdate: the assignment names a bare resource
1617
        // IRI; it is validated per-ref for every Space ref of that IRI whose admin set
1618
        // contains the publisher. The inserted subject is minted per (assignment, ref, role)
1619
        // — one assignment fans out to N roles and N refs. Non-Space targets resolve no
1620
        // ?targetRef and so insert nothing (correct no-op; maintained-resource / individual
1621
        // targets are future work, see design doc §2). TRANSITIONAL-DUAL-EMIT (Phase 4:
1622
        // remove): forSpace kept alongside forSpaceRef so the non-admin tiers can probe the
1623
        // IRI-keyed instantiations and pre-ref read queries keep functioning.
1624
        return """
69 ✔
1625
                PREFIX npa:  <%1$s>
1626
                PREFIX gen:  <%2$s>
1627
                INSERT { GRAPH <%3$s> {
1628
                  ?ra2 a gen:RoleAssignment ;
1629
                       npa:forSpaceRef ?targetRef ;
1630
                       npa:forSpace    ?resource ;
1631
                       gen:hasRole     ?role ;
1632
                       npa:viaNanopub  ?assignNp ;
1633
                       npa:derivedFromPreset ?assignNp .
1634
                } }
1635
                WHERE {
1636
                  # 1. Anchor: active preset assignments in the extraction graph.
1637
                  GRAPH <%4$s> {
1638
                    ?pa a npa:PresetAssignment ;
1639
                        npa:ofPreset    ?preset ;
1640
                        npa:forResource ?resource ;
1641
                        npa:isActivated true ;
1642
                        npa:pubkeyHash  ?pkh ;
1643
                        npa:viaNanopub  ?assignNp ;
1644
                        <http://purl.org/dc/terms/created> ?created .
1645
                  }
1646
                  # 2. Load-number filter on the assignment nanopub.
1647
                  GRAPH <%7$s> {
1648
                    ?assignNp npa:hasLoadNumber ?ln .
1649
                    FILTER (?ln > %5$d)
1650
                  }
1651
                  # 3. Resolve publisher pkh -> agent via the mirrored trust-approved row.
1652
                  GRAPH <%3$s> {
1653
                    ?acct a npa:AccountState ;
1654
                          npa:agent  ?publisher ;
1655
                          npa:pubkey ?pkh .
1656
                  }
1657
                  # 4. Target must be a Space ref the publisher admins — direct, or the
1658
                  #    canonical ref ?resource is an owl:sameAs alias of (issue #113 parity
1659
                  #    with attachmentValidationUpdate, so a preset assigned against an alias
1660
                  #    IRI still materializes against the canonical ref).
1661
                  {
1662
                    GRAPH <%4$s> { ?targetRef npa:spaceIri ?resource . }
1663
                    GRAPH <%3$s> {
1664
                      ?adminRI a gen:RoleInstantiation ;
1665
                               npa:forSpaceRef ?targetRef ;
1666
                               npa:inverseProperty gen:hasAdmin ;
1667
                               npa:forAgent ?publisher .
1668
                    }
1669
                  }
1670
                  UNION
1671
                  {
1672
                    GRAPH <%3$s> {
1673
                      ?resource npa:sameAsSpace ?targetRef .
1674
                      ?adminRI a gen:RoleInstantiation ;
1675
                               npa:forSpaceRef ?targetRef ;
1676
                               npa:inverseProperty gen:hasAdmin ;
1677
                               npa:forAgent ?publisher .
1678
                    }
1679
                  }
1680
                  # 5. Resolve the assignment's referenced preset IRI (node or kind) to its
1681
                  #    canonical kind, mirroring how Nanodash views key on dct:isVersionOf
1682
                  #    (ViewDisplay.getViewKindIri). Every declaration carries npa:ofPreset for
1683
                  #    both its node IRI and kind, so either reference maps to the same ?kind.
1684
                  GRAPH <%4$s> {
1685
                    ?pdMap a npa:PresetDeclaration ;
1686
                           npa:ofPreset   ?preset ;
1687
                           npa:presetKind ?kind .
1688
                  }
1689
                  # 5a. Roles come from the LATEST live declaration of that kind, restricted to
1690
                  #     Space-targeted presets — so a superseded preset version's roles never leak
1691
                  #     (the per-view-kind latest-wins, ported to materialization).
1692
                  GRAPH <%4$s> {
1693
                    ?pd a npa:PresetDeclaration ;
1694
                        npa:presetKind           ?kind ;
1695
                        npa:presetRole           ?role ;
1696
                        npa:appliesToInstancesOf gen:Space ;
1697
                        npa:viaNanopub           ?pdNp ;
1698
                        <http://purl.org/dc/terms/created> ?pdCreated .
1699
                  }
1700
                  # 5b. Latest-declaration-per-kind: reject if a newer LIVE declaration of the
1701
                  #     same kind exists (tiebreak on subject IRI for equal timestamps).
1702
                  FILTER NOT EXISTS {
1703
                    GRAPH <%4$s> {
1704
                      ?pdNewer a npa:PresetDeclaration ;
1705
                               npa:presetKind ?kind ;
1706
                               npa:viaNanopub ?pdNpNewer ;
1707
                               <http://purl.org/dc/terms/created> ?pdCreatedNewer .
1708
                      FILTER (?pdCreatedNewer > ?pdCreated
1709
                              || (?pdCreatedNewer = ?pdCreated && STR(?pdNewer) > STR(?pd)))
1710
                    }
1711
                    %8$s
1712
                  }
1713
                  # 5c. The chosen declaration must itself be live (not superseded/retracted).
1714
                  %9$s
1715
                  # 6. Mint the per (assignment, ref, role) subject.
1716
                  BIND(IRI(CONCAT(STR(?pa), "__", ENCODE_FOR_URI(STR(?targetRef)),
1717
                                  "__", ENCODE_FOR_URI(STR(?role)))) AS ?ra2)
1718
                  # 7. Authorization-scoped latest-wins (anti-hijack, design doc §3): reject
1719
                  #    if a newer same-(preset,resource) assignment exists whose publisher is
1720
                  #    ALSO a validated admin of ?targetRef. Filtering the shadowing candidate
1721
                  #    to admin-authored rows BEFORE taking the latest is what stops an
1722
                  #    unauthorized key from suppressing an admin's activation. Placed after
1723
                  #    the main vars are bound so the planner defers it (RDF4J quirk).
1724
                  FILTER NOT EXISTS {
1725
                    GRAPH <%4$s> {
1726
                      ?paNewer a npa:PresetAssignment ;
1727
                               npa:ofPreset    ?preset ;
1728
                               npa:forResource ?resource ;
1729
                               npa:pubkeyHash  ?pkhNewer ;
1730
                               <http://purl.org/dc/terms/created> ?createdNewer .
1731
                      FILTER (?createdNewer > ?created
1732
                              || (?createdNewer = ?created && STR(?paNewer) > STR(?pa)))
1733
                    }
1734
                    GRAPH <%3$s> {
1735
                      ?acctNewer a npa:AccountState ;
1736
                                 npa:agent  ?publisherNewer ;
1737
                                 npa:pubkey ?pkhNewer .
1738
                      ?adminRINewer a gen:RoleInstantiation ;
1739
                                    npa:forSpaceRef ?targetRef ;
1740
                                    npa:inverseProperty gen:hasAdmin ;
1741
                                    npa:forAgent ?publisherNewer .
1742
                    }
1743
                  }
1744
                  # 8. Defensive: drop if the assignment nanopub itself was hard-retracted.
1745
                  %6$s
1746
                  # 8a. Detachment latest-wins (issue #129): suppress if a newer admin-authored
1747
                  #     gen:detachedRole out-ranks this preset-derived (ref, role) attachment.
1748
                  #     Non-sticky: a newer PresetAssignment (newer ?created) re-attaches.
1749
                  %10$s
1750
                  # 9. Dedup last — keyed on (ref, role).
1751
                  FILTER NOT EXISTS { GRAPH <%3$s> {
1752
                    ?existing a gen:RoleAssignment ;
1753
                              npa:forSpaceRef ?targetRef ;
1754
                              gen:hasRole ?role .
1755
                  } }
1756
                }
1757
                """.formatted(
3 ✔
1758
                NPA.NAMESPACE,
1759
                GEN.NAMESPACE,
1760
                graph,
1761
                SpacesVocab.SPACES_GRAPH,
1762
                lastProcessed,
15 ✔
1763
                invalidationFilter("assignNp"),
27 ✔
1764
                NPA.GRAPH,
1765
                invalidationFilter("pdNpNewer"),
15 ✔
1766
                invalidationFilter("pdNp"),
21 ✔
1767
                roleDetachmentSuppressionFilter(graph, "created", "pa"));
6 ✔
1768
    }
1769

1770
    /**
1771
     * Stamps a ref-scoped, admin-validated mirror of each {@code npa:PresetAssignment}
1772
     * into the state graph (issue #122). The publisher-agnostic extraction row
1773
     * ({@link SpacesExtractor#extractPresetAssignment}) is keyed only by
1774
     * {@code npa:forResource}, so a consumer listing a space's preset assignments by IRI
1775
     * sees the union across <em>all</em> refs claiming that IRI. This stamp adds
1776
     * {@code npa:forSpaceRef ?targetRef} so the "Assigned presets" listing is no longer
1777
     * merged across refs of the same IRI — the one remaining About-tab listing that still
1778
     * merged across refs (every other ref-scoped listing already has a {@code forSpaceRef}
1779
     * companion).
1780
     *
1781
     * <p>Faithful per-assignment mirror — deliberately <em>not</em> role-gated and
1782
     * <em>not</em> latest-wins-resolved, unlike {@link #presetAttachmentValidationUpdate}:
1783
     * <ul>
1784
     *   <li>No {@code npa:PresetDeclaration}/role join, so a preset that bundles only
1785
     *       <em>views</em> (no roles) is still listed.</li>
1786
     *   <li>Emits active <em>and</em> deactivated rows (carries {@code npa:isActivated})
1787
     *       so the listing can show state; a deactivation is just a newer admin-authored
1788
     *       row, so no {@code dct:created}-driven removal is needed here (contrast §4.4).</li>
1789
     *   <li>Latest-wins is deferred to the consumer query, which ranges only over these
1790
     *       admin-authored rows — so it is authorization-scoped for free (design §3): a
1791
     *       non-admin of the ref can never get a row stamped, so it cannot enter the
1792
     *       latest-wins race.</li>
1793
     * </ul>
1794
     *
1795
     * <p>Display-only leaf: nothing downstream derives from these rows (contrast the
1796
     * preset-derived {@code gen:RoleAssignment}), so the caller must <em>not</em> feed this
1797
     * tier's count into {@code structuralAdds}. The {@code npa:forSpaceRef} predicate also
1798
     * distinguishes a stamped row from the IRI-keyed extraction row (which never carries it),
1799
     * so {@link #presetAssignmentRefInvalidationDelete} can target exactly these rows.
1800
     * Reuses steps 1–4 of {@link #presetAttachmentValidationUpdate}; see
1801
     * doc/design-preset-role-materialization.md §3 and issue #122.
1802
     */
1803
    static String presetAssignmentRefStampUpdate(IRI graph, long lastProcessed) {
1804
        return """
69 ✔
1805
                PREFIX npa:  <%1$s>
1806
                PREFIX gen:  <%2$s>
1807
                INSERT { GRAPH <%3$s> {
1808
                  ?paRef a npa:PresetAssignment ;
1809
                         npa:ofPreset    ?preset ;
1810
                         npa:forResource ?resource ;
1811
                         npa:forSpaceRef ?targetRef ;
1812
                         npa:isActivated ?activated ;
1813
                         npa:viaNanopub  ?assignNp ;
1814
                         <http://purl.org/dc/terms/created> ?created .
1815
                } }
1816
                WHERE {
1817
                  # 1. Anchor: every assignment row (active or not) in the extraction graph.
1818
                  GRAPH <%4$s> {
1819
                    ?pa a npa:PresetAssignment ;
1820
                        npa:ofPreset    ?preset ;
1821
                        npa:forResource ?resource ;
1822
                        npa:isActivated ?activated ;
1823
                        npa:pubkeyHash  ?pkh ;
1824
                        npa:viaNanopub  ?assignNp ;
1825
                        <http://purl.org/dc/terms/created> ?created .
1826
                  }
1827
                  # 2. Load-number filter on the assignment nanopub (delta window).
1828
                  GRAPH <%6$s> {
1829
                    ?assignNp npa:hasLoadNumber ?ln .
1830
                    FILTER (?ln > %5$d)
1831
                  }
1832
                  # 3. Resolve publisher pkh -> agent via the mirrored trust-approved row.
1833
                  GRAPH <%3$s> {
1834
                    ?acct a npa:AccountState ;
1835
                          npa:agent  ?publisher ;
1836
                          npa:pubkey ?pkh .
1837
                  }
1838
                  # 4. Target must be a Space ref the publisher admins. ?targetRef = that ref;
1839
                  #    fan-out to N refs the publisher admins (per-ref isolation, consistent
1840
                  #    with the role materializer and design-spaceref-isolation.md). Direct,
1841
                  #    or the canonical ref ?resource is an owl:sameAs alias of (issue #113),
1842
                  #    so an assignment naming an alias is still listed under the canonical ref.
1843
                  {
1844
                    GRAPH <%4$s> { ?targetRef npa:spaceIri ?resource . }
1845
                    GRAPH <%3$s> {
1846
                      ?adminRI a gen:RoleInstantiation ;
1847
                               npa:forSpaceRef ?targetRef ;
1848
                               npa:inverseProperty gen:hasAdmin ;
1849
                               npa:forAgent ?publisher .
1850
                    }
1851
                  }
1852
                  UNION
1853
                  {
1854
                    GRAPH <%3$s> {
1855
                      ?resource npa:sameAsSpace ?targetRef .
1856
                      ?adminRI a gen:RoleInstantiation ;
1857
                               npa:forSpaceRef ?targetRef ;
1858
                               npa:inverseProperty gen:hasAdmin ;
1859
                               npa:forAgent ?publisher .
1860
                    }
1861
                  }
1862
                  # 5. Defensive: drop if the assignment nanopub itself was hard-retracted.
1863
                  %7$s
1864
                  # 6. Mint per (assignment, ref); dedup on the bound subject. No latest-wins
1865
                  #    here — a deactivation is just a newer admin-authored row, and the
1866
                  #    consumer resolves latest dct:created per (preset,resource) over these
1867
                  #    admin-authored rows (so the resolution is authorization-scoped).
1868
                  BIND(IRI(CONCAT(STR(?pa), "__", ENCODE_FOR_URI(STR(?targetRef)))) AS ?paRef)
1869
                  FILTER NOT EXISTS { GRAPH <%3$s> { ?paRef a npa:PresetAssignment . } }
1870
                }
1871
                """.formatted(
3 ✔
1872
                NPA.NAMESPACE,
1873
                GEN.NAMESPACE,
1874
                graph,
1875
                SpacesVocab.SPACES_GRAPH,
1876
                lastProcessed,
27 ✔
1877
                NPA.GRAPH,
1878
                invalidationFilter("assignNp"));
6 ✔
1879
    }
1880

1881
    /**
1882
     * Non-admin tier publisher constraints (inserted as a SPARQL sub-pattern).
1883
     * Each constraint owns the AccountState (pkh → agent) lookup so the join
1884
     * variable is bound through a targeted pattern. The observer-self variant
1885
     * binds {@code npa:agent ?agent} directly — no separate {@code ?publisher}
1886
     * variable, no post-join equality filter — which lets the planner anchor
1887
     * the AccountState lookup on the already-bound {@code ?agent} instead of
1888
     * enumerating all approved publishers and filtering at the end.
1889
     */
1890
    static final String PUBLISHER_IS_ADMIN = """
1891
            ?acct a npa:AccountState ;
1892
                  npa:pubkey ?pkh ;
1893
                  npa:agent  ?publisher .
1894
            # Admin of the assignment's ref. The ref already resolves alias →
1895
            # canonical (the attachment tier bound ?spaceRef through the owl:sameAs
1896
            # alias edge for aliased IRIs, issue #113), so no alias arm is needed here.
1897
            ?adminRI a gen:RoleInstantiation ;
1898
                     npa:forSpaceRef ?spaceRef ;
1899
                     npa:inverseProperty gen:hasAdmin ;
1900
                     npa:forAgent ?publisher .
1901
            """;
1902

1903
    /** Observer self-evidence: the assignee's own pubkey signed the instantiation. */
1904
    static final String PUBLISHER_IS_SELF = """
1905
            ?acct a npa:AccountState ;
1906
                  npa:pubkey ?pkh ;
1907
                  npa:agent  ?agent .
1908
            """;
1909

1910
    /**
1911
     * Observer self-evidence from a not-yet-approved account (issue #195): same shape,
1912
     * but resolved through the pending row {@link #mirrorPendingAccounts} wrote.
1913
     *
1914
     * <p>Safe only because the observer tier is self-assignable by the tier model — the
1915
     * grant carries no authority, and {@code ?agent} is already bound by the
1916
     * instantiation, so this can never bind a pending key to a <em>different</em> agent.
1917
     * It is the single place in this class where a non-approved row is accepted; every
1918
     * other constraint keeps matching {@code npa:AccountState}.
1919
     */
1920
    static final String PUBLISHER_IS_SELF_PENDING = """
1921
            ?acct a npa:PendingAccountState ;
1922
                  npa:pubkey ?pkh ;
1923
                  npa:agent  ?agent .
1924
            """;
1925

1926
    /**
1927
     * Extra INSERT triples stamped on roles materialized through a pending account, so
1928
     * read queries can render them as "pending approval" with a one-triple OPTIONAL
1929
     * instead of re-deriving the account's status.
1930
     */
1931
    static final String PENDING_ROLE_STAMP = "npa:trustStatus npa:seen ;\n                       ";
1932

1933
    /**
1934
     * Maintainer / Member / Observer tier INSERT. Same shape: find an instantiation
1935
     * whose predicate matches a RoleDeclaration of the given tier attached to the
1936
     * target space, and whose publisher passes the tier-specific constraint.
1937
     */
1938
    static String nonAdminTierUpdate(IRI graph, long lastProcessed,
1939
                                     IRI tierClass, String publisherConstraint) {
1940
        return nonAdminTierUpdate(graph, lastProcessed, tierClass, publisherConstraint, "");
21 ✔
1941
    }
1942

1943
    /**
1944
     * Variant that stamps {@code extraInsert} onto every materialized row — used by the
1945
     * pending-account observer pass to mark the row as awaiting trust approval.
1946
     */
1947
    static String nonAdminTierUpdate(IRI graph, long lastProcessed, IRI tierClass,
1948
                                     String publisherConstraint, String extraInsert) {
1949
        // Order tuned for RDF4J's evaluator (which executes BGPs roughly in order).
1950
        // The crucial choice is the *anchor*: instantiation-first plans send the
1951
        // planner exploring the full ~thousands of candidate RIs and only filter
1952
        // by tier at the very end. Attachment-first anchors on the small set of
1953
        // gen:RoleAssignment rows already validated in this space-state graph
1954
        // (~hundreds, often zero) and walks outward by bound (?role, ?space).
1955
        //
1956
        //   1. Anchor on RoleAssignments in this space-state graph (small).
1957
        //   1a. Resolve the IRIs that denote the assignment's ref — its canonical
1958
        //      IRI plus any validated owl:sameAs aliases — so an instantiation that
1959
        //      names an alias of the space still matches (issue #113). Bound here so
1960
        //      the instantiation lookup below stays anchored by ?instSpace.
1961
        //   2. Match the tier-pinned RoleDeclaration by ?role.
1962
        //   3. Pair role-decl direction to instantiation direction in one UNION
1963
        //      so only (reg, reg)/(inv, inv) combos are explored.
1964
        //   4. Targeted instantiation lookup — (?instSpace, ?pred) are bound.
1965
        //   5. Publisher constraint (incl. AccountState resolution).
1966
        //   6. Load-number filter on bound ?np.
1967
        //   7. Dedup at the end.
1968
        return """
69 ✔
1969
                PREFIX npa:  <%1$s>
1970
                PREFIX gen:  <%2$s>
1971
                INSERT { GRAPH <%3$s> {
1972
                  ?ri2 a gen:RoleInstantiation ;
1973
                       npa:forSpaceRef ?spaceRef ;
1974
                       # TRANSITIONAL-DUAL-EMIT (Phase 4: remove): forSpace alongside
1975
                       # forSpaceRef so pre-ref read queries (e.g. get-space-members) keep
1976
                       # functioning on a mixed-version fleet; downstream tiers key on the ref.
1977
                       npa:forSpace ?space ;
1978
                       npa:forAgent ?agent ;
1979
                       ?dirPred ?pred ;
1980
                       # Persist the tier and role IRI that are already bound at this point —
1981
                       # the loop's tierClass arg (%7$s) and the anchoring attachment's ?role
1982
                       # (step 1) — so ref-scoped consumers key on identity rather than
1983
                       # re-deriving the tier from the bare predicate against GLOBAL
1984
                       # RoleDeclarations. The bare-predicate re-derivation bleeds tiers
1985
                       # across spaces that declare the same predicate at different tiers
1986
                       # (issue #125): consumers should match ?ri2 npa:hasRoleType <tier>
1987
                       # / gen:hasRole ?role, exactly as the *-roles-ref queries do.
1988
                       npa:hasRoleType <%7$s> ;
1989
                       gen:hasRole ?role ;
1990
                       %12$snpa:viaNanopub ?np .
1991
                } }
1992
                WHERE {
1993
                  # 1. Anchor: validated attachments in this space-state graph (ref-keyed).
1994
                  GRAPH <%3$s> {
1995
                    ?ra a gen:RoleAssignment ;
1996
                        gen:hasRole     ?role ;
1997
                        npa:forSpaceRef ?spaceRef ;
1998
                        npa:forSpace    ?space .
1999
                  }
2000
                  # 1a. The IRIs that denote this ref: its canonical IRI, plus any validated
2001
                  #     owl:sameAs aliases of it (issue #113) — so an instantiation naming an
2002
                  #     alias of the space still materializes here. Bound BEFORE the
2003
                  #     instantiation BGP so that lookup stays anchored by ?instSpace (planner
2004
                  #     note above); ?spaceRef is already bound, so each arm is a targeted
2005
                  #     lookup yielding a tiny IRI set. The alias arm only follows admin-
2006
                  #     validated npa:sameAsSpace edges, so it grants no authority the admin
2007
                  #     tier would not (anti-hijack is enforced upstream, not relaxed here).
2008
                  {
2009
                    GRAPH <%4$s> { ?spaceRef npa:spaceIri ?instSpace . }
2010
                  }
2011
                  UNION
2012
                  {
2013
                    GRAPH <%3$s> { ?instSpace npa:sameAsSpace ?spaceRef . }
2014
                  }
2015
                  # 2. Tier-pinned RoleDeclaration (?role bound from the attachment). Its
2016
                  #    nanopub's invalidation is intentionally NOT consulted (see step 7), so
2017
                  #    no ?rdNp binding is needed.
2018
                  GRAPH <%4$s> {
2019
                    ?rd a npa:RoleDeclaration ;
2020
                        npa:hasRoleType <%7$s> ;
2021
                        npa:role        ?role .
2022
                    # 3. Pair role-decl direction to the instantiation in one UNION so only
2023
                    #    matching combos are explored, binding (?instSpace, ?agent) per arm.
2024
                    #    ?dirPred carries the resolved direction so the materialized row
2025
                    #    records the role property (read by get-space-members and
2026
                    #    publisherIsTieredRole) — identical shape whichever arm matched.
2027
                    #
2028
                    #    The first two arms handle instantiations the extractor already
2029
                    #    classified (npa:regularProperty / npa:inverseProperty). The last two
2030
                    #    resolve a custom predicate the extractor left neutral (npa:rolePredicate
2031
                    #    with raw npa:bindingSubject / npa:bindingObject): the role declaration
2032
                    #    supplies the direction, which fixes which raw endpoint is the space vs
2033
                    #    the agent. INVERSE = <space> pred <agent>; REGULAR = <agent> pred <space>.
2034
                    {
2035
                      ?rd gen:hasRegularProperty ?pred .
2036
                      ?ri npa:regularProperty ?pred ;
2037
                          npa:forSpace ?instSpace ;
2038
                          npa:forAgent ?agent .
2039
                      BIND(npa:regularProperty AS ?dirPred)
2040
                    }
2041
                    UNION
2042
                    {
2043
                      ?rd gen:hasInverseProperty ?pred .
2044
                      ?ri npa:inverseProperty ?pred ;
2045
                          npa:forSpace ?instSpace ;
2046
                          npa:forAgent ?agent .
2047
                      BIND(npa:inverseProperty AS ?dirPred)
2048
                    }
2049
                    UNION
2050
                    {
2051
                      ?rd gen:hasInverseProperty ?pred .
2052
                      ?ri npa:rolePredicate   ?pred ;
2053
                          npa:bindingSubject  ?instSpace ;
2054
                          npa:bindingObject   ?agent .
2055
                      BIND(npa:inverseProperty AS ?dirPred)
2056
                    }
2057
                    UNION
2058
                    {
2059
                      ?rd gen:hasRegularProperty ?pred .
2060
                      ?ri npa:rolePredicate   ?pred ;
2061
                          npa:bindingObject   ?instSpace ;
2062
                          npa:bindingSubject  ?agent .
2063
                      BIND(npa:regularProperty AS ?dirPred)
2064
                    }
2065
                    # 4. Common instantiation columns. ?instSpace was resolved to this ref
2066
                    #    above (canonical or owl:sameAs alias), so an alias-named instantiation
2067
                    #    joins the same ?spaceRef as a canonical one. The materialized row still
2068
                    #    carries npa:forSpace ?space (the attachment's IRI) for the transitional
2069
                    #    dual-emit, so pre-ref reads see the member under the space's primary IRI.
2070
                    ?ri a gen:RoleInstantiation ;
2071
                        npa:pubkeyHash ?pkh ;
2072
                        npa:viaNanopub ?np .
2073
                    # Candidate grant timestamp for the revocation latest-wins (issue #129);
2074
                    # absent ⇒ epoch (always loses).
2075
                    OPTIONAL { ?ri <http://purl.org/dc/terms/created> ?candCreatedRaw . }
2076
                  }
2077
                  BIND(COALESCE(?candCreatedRaw, %11$s) AS ?candCreated)
2078
                  # 5. Publisher constraint (incl. AccountState resolution).
2079
                  GRAPH <%3$s> {
2080
                    %8$s
2081
                  }
2082
                  # 5a. Mint the per-ref state subject: (?ri, ?spaceRef) → ?ri2.
2083
                  BIND(IRI(CONCAT(STR(?ri), "__", ENCODE_FOR_URI(STR(?spaceRef)))) AS ?ri2)
2084
                  # 6. Load-number filter on bound ?np.
2085
                  GRAPH <%9$s> {
2086
                    ?np npa:hasLoadNumber ?ln .
2087
                    FILTER (?ln > %5$d)
2088
                  }
2089
                  # 7. Instantiation invalidation filter — outside the GRAPH block so the
2090
                  #    planner defers it until ?np is bound. Role-DECLARATION invalidation is
2091
                  #    deliberately NOT consulted: the tier already anchors on the admin-
2092
                  #    validated attachment (?ra), which is removed when an admin retracts it,
2093
                  #    so admin control is fully enforced there. Letting the declaration's
2094
                  #    author (usually not the space admin) supersede/retract their declaration
2095
                  #    strip a space's members is the same cross-author-strip anti-pattern as
2096
                  #    issue #112. Role IRIs are version-pinned, so the attached definition is
2097
                  #    immutable regardless of the declaration nanopub's later lifecycle.
2098
                  %6$s
2099
                  # 7a. Revocation latest-wins (issue #129): suppress if a newer authorized
2100
                  #     gen:RevokedRoleInstantiation shadows this (space, agent, role) key.
2101
                  %10$s
2102
                  # 8. Dedup last — keyed on (ref, agent, nanopub).
2103
                  FILTER NOT EXISTS { GRAPH <%3$s> {
2104
                    ?existing a gen:RoleInstantiation ;
2105
                              npa:forSpaceRef ?spaceRef ;
2106
                              npa:forAgent ?agent ;
2107
                              npa:viaNanopub ?np .
2108
                  } }
2109
                }
2110
                """.formatted(
3 ✔
2111
                NPA.NAMESPACE,
2112
                GEN.NAMESPACE,
2113
                graph,
2114
                SpacesVocab.SPACES_GRAPH,
2115
                lastProcessed,
15 ✔
2116
                invalidationFilter("np"),
54 ✔
2117
                tierClass,
2118
                publisherConstraint,
2119
                NPA.GRAPH,
2120
                nonAdminRevocationSuppressionFilter(graph, tierClass),
30 ✔
2121
                EPOCH_DT,
2122
                extraInsert);
2123
    }
2124

2125
    /**
2126
     * Sub-space admit pass. Copies validated {@code npa:SubSpaceDeclaration}
2127
     * extraction rows into the space-state graph (preserving the {@code npasub:}
2128
     * subject) and emits convenience {@code <child> npa:isSubSpaceOf <parent>} and
2129
     * {@code <parent> npa:hasSubSpace <child>} direct triples. Two satisfaction
2130
     * modes joined by UNION:
2131
     * <ul>
2132
     *   <li>Mode A — the declaration's publisher is a validated admin of both the
2133
     *       child and the parent space.</li>
2134
     *   <li>Mode B — a different non-invalidated declaration for the same
2135
     *       {@code (child, parent)} pair exists, and the two publishers between
2136
     *       them cover both admin sides (i.e. one of them is admin of the child,
2137
     *       one of them is admin of the parent — possibly the same one twice if
2138
     *       both happen to be admin of both).</li>
2139
     * </ul>
2140
     *
2141
     * <p>Mode-B late-arrival: when only the partner declaration is new in this
2142
     * cycle (the primary is older than {@code lastProcessed}), the load-number
2143
     * filter on {@code ?np} excludes the candidate. The late-arrival sweep
2144
     * ({@link #runDownstreamWithoutLoadFilter}) re-runs this pass without the
2145
     * load filter and catches it.
2146
     */
2147
    static String subSpaceAdmitUpdate(IRI graph, long lastProcessed) {
2148
        return """
69 ✔
2149
                PREFIX npa: <%1$s>
2150
                PREFIX gen: <%2$s>
2151
                INSERT { GRAPH <%3$s> {
2152
                  ?d a npa:SubSpaceDeclaration ;
2153
                     npa:childSpace  ?child ;
2154
                     npa:parentSpace ?parent ;
2155
                     npa:viaNanopub  ?np .
2156
                  ?childRef  npa:isSubSpaceOf ?parentRef .
2157
                  ?parentRef npa:hasSubSpace  ?childRef  .
2158
                  # Reified per-(nanopub, ref-pair) provenance link (issue #125 finding #5):
2159
                  # carries npa:viaNanopub plus both the ref and IRI endpoints, so the
2160
                  # invalidation cleanup can drop the convenience edges below once no
2161
                  # surviving link backs them — instead of leaving them sticky until the
2162
                  # next periodic full rebuild.
2163
                  ?ssLink a npa:SubSpaceLink ;
2164
                          npa:viaNanopub     ?np ;
2165
                          npa:childSpaceRef  ?childRef ;
2166
                          npa:parentSpaceRef ?parentRef ;
2167
                          npa:childSpace     ?child ;
2168
                          npa:parentSpace    ?parent .
2169
                  # TRANSITIONAL-DUAL-EMIT (Phase 1.5; remove in Phase 4): IRI-valued
2170
                  # sub-space edge alongside the ref-to-ref one, so pre-ref published
2171
                  # queries that key on the bare Space IRI keep binding on a mixed-version
2172
                  # fleet. See doc/report-2026-06-12-mixed-fleet-spaceref-breakage.md.
2173
                  ?child  npa:isSubSpaceOf ?parent .
2174
                  ?parent npa:hasSubSpace  ?child  .
2175
                } }
2176
                WHERE {
2177
                  # 1. Anchor: candidate declarations from the extraction graph.
2178
                  GRAPH <%4$s> {
2179
                    ?d a npa:SubSpaceDeclaration ;
2180
                       npa:childSpace  ?child ;
2181
                       npa:parentSpace ?parent ;
2182
                       npa:pubkeyHash  ?pkh ;
2183
                       npa:viaNanopub  ?np .
2184
                  }
2185
                  # 2. Mirror: resolve ?pkh → ?publisher via the trust-approved row.
2186
                  GRAPH <%3$s> {
2187
                    ?acct a npa:AccountState ;
2188
                          npa:pubkey ?pkh ;
2189
                          npa:agent  ?publisher .
2190
                  }
2191
                  # 3. Authority gate, ref-keyed. The edge is emitted ref-to-ref between
2192
                  #    the child ref and parent ref the authorizing admin governs; the
2193
                  #    admin rows' dual-emitted npa:forSpace binds the refs to the child /
2194
                  #    parent IRIs (cross-product when an IRI has several governed refs).
2195
                  {
2196
                    # Mode A — publisher is admin of BOTH a child ref and a parent ref.
2197
                    GRAPH <%3$s> {
2198
                      ?riC a gen:RoleInstantiation ;
2199
                           npa:inverseProperty gen:hasAdmin ;
2200
                           npa:forSpace ?child ;
2201
                           npa:forSpaceRef ?childRef ;
2202
                           npa:forAgent ?publisher .
2203
                      ?riP a gen:RoleInstantiation ;
2204
                           npa:inverseProperty gen:hasAdmin ;
2205
                           npa:forSpace ?parent ;
2206
                           npa:forSpaceRef ?parentRef ;
2207
                           npa:forAgent ?publisher .
2208
                    }
2209
                  }
2210
                  UNION
2211
                  {
2212
                    # Mode B — co-declaration whose publisher covers the side this
2213
                    # one's publisher doesn't. Between {publisher, publisher2},
2214
                    # both admin sides must be covered.
2215
                    GRAPH <%4$s> {
2216
                      ?d2 a npa:SubSpaceDeclaration ;
2217
                          npa:childSpace  ?child ;
2218
                          npa:parentSpace ?parent ;
2219
                          npa:pubkeyHash  ?pkh2 ;
2220
                          npa:viaNanopub  ?np2 .
2221
                      FILTER (?np2 != ?np)
2222
                    }
2223
                    %8$s
2224
                    GRAPH <%3$s> {
2225
                      ?acct2 a npa:AccountState ;
2226
                             npa:pubkey ?pkh2 ;
2227
                             npa:agent  ?publisher2 .
2228
                      ?riA a gen:RoleInstantiation ;
2229
                           npa:inverseProperty gen:hasAdmin ;
2230
                           npa:forSpace ?child ;
2231
                           npa:forSpaceRef ?childRef .
2232
                      { ?riA npa:forAgent ?publisher } UNION { ?riA npa:forAgent ?publisher2 }
2233
                      ?riB a gen:RoleInstantiation ;
2234
                           npa:inverseProperty gen:hasAdmin ;
2235
                           npa:forSpace ?parent ;
2236
                           npa:forSpaceRef ?parentRef .
2237
                      { ?riB npa:forAgent ?publisher } UNION { ?riB npa:forAgent ?publisher2 }
2238
                    }
2239
                  }
2240
                  # 4. Invalidation filter on the primary declaration's nanopub.
2241
                  %6$s
2242
                  # 5. Load-number filter on bound ?np.
2243
                  GRAPH <%7$s> {
2244
                    ?np npa:hasLoadNumber ?ln .
2245
                    FILTER (?ln > %5$d)
2246
                  }
2247
                  # 6. Mint the per-(nanopub, ref-pair) provenance link IRI and dedup on it
2248
                  #    (not on the bare edge). Keyed on ?np so every backing declaration of
2249
                  #    the same ref-pair records its own removable link; the convenience
2250
                  #    edges above are re-asserted idempotently.
2251
                  BIND(IRI(CONCAT("http://purl.org/nanopub/admin/spacelink/subspace/",
2252
                                  MD5(CONCAT(STR(?np), "|", STR(?childRef), "|", STR(?parentRef))))) AS ?ssLink)
2253
                  FILTER NOT EXISTS { GRAPH <%3$s> {
2254
                    ?ssLink a npa:SubSpaceLink .
2255
                  } }
2256
                }
2257
                """.formatted(
3 ✔
2258
                NPA.NAMESPACE,
2259
                GEN.NAMESPACE,
2260
                graph,
2261
                SpacesVocab.SPACES_GRAPH,
2262
                lastProcessed,
15 ✔
2263
                invalidationFilter("np"),
27 ✔
2264
                NPA.GRAPH,
2265
                invalidationFilter("np2"));
6 ✔
2266
    }
2267

2268
    /**
2269
     * Maintained-resource admit pass. Copies validated
2270
     * {@code npa:MaintainedResourceDeclaration} extraction rows into the space-state
2271
     * graph (preserving the {@code npamrd:} subject) and emits convenience
2272
     * {@code <r> npa:isMaintainedBy <s>} and {@code <s> npa:hasMaintainedResource <r>}
2273
     * direct triples. Single satisfaction mode:
2274
     * <ul>
2275
     *   <li>Mode A — the declaration's publisher is a validated admin of the
2276
     *       maintaining space.</li>
2277
     * </ul>
2278
     *
2279
     * <p>No Mode B because only one space is involved; the two-sides-must-be-covered
2280
     * concern that drives sub-space Mode B doesn't apply. Late-arrival is still
2281
     * possible (declaration lands before the publisher's admin grant becomes valid):
2282
     * the load-number filter on {@code ?np} excludes the candidate, and the
2283
     * late-arrival sweep ({@link #runDownstreamWithoutLoadFilter}) re-runs this pass
2284
     * without the load filter and catches it.
2285
     */
2286
    static String maintainedResourceAdmitUpdate(IRI graph, long lastProcessed) {
2287
        return """
69 ✔
2288
                PREFIX npa: <%1$s>
2289
                PREFIX gen: <%2$s>
2290
                INSERT { GRAPH <%3$s> {
2291
                  ?d a npa:MaintainedResourceDeclaration ;
2292
                     npa:resourceIri     ?r ;
2293
                     npa:maintainerSpace ?s ;
2294
                     npa:viaNanopub      ?np .
2295
                  ?r npa:isMaintainedBy        ?sRef .
2296
                  ?sRef npa:hasMaintainedResource ?r .
2297
                  # Uniform ref-valued resource→governing-space-ref edge (issue #130). The
2298
                  # same predicate the reflexive space self-edge uses, so a single consumer
2299
                  # hop covers both "resource maintained by space S" and "resource IS a space".
2300
                  # Backed by the same MaintainedResourceLink below, so the invalidation
2301
                  # cleanup sweeps it alongside isMaintainedBy.
2302
                  ?r npa:hasGoverningSpaceRef  ?sRef .
2303
                  # Reified per-(nanopub, resource→ref) provenance link (issue #125 finding
2304
                  # #5): lets the invalidation cleanup drop the convenience edges below once
2305
                  # no surviving link backs them, instead of leaving them sticky.
2306
                  ?mrLink a npa:MaintainedResourceLink ;
2307
                          npa:viaNanopub         ?np ;
2308
                          npa:resourceIri        ?r ;
2309
                          npa:maintainerSpaceRef ?sRef ;
2310
                          npa:maintainerSpace    ?s .
2311
                  # TRANSITIONAL-DUAL-EMIT (Phase 1.5; remove in Phase 4): IRI-valued
2312
                  # maintained-resource edge alongside the resource→ref one, so pre-ref
2313
                  # published queries (e.g. get-view-displays' maintained hop) keep binding
2314
                  # on a mixed-version fleet. This is the edge whose absence broke 1.15.0 —
2315
                  # see doc/report-2026-06-12-mixed-fleet-spaceref-breakage.md.
2316
                  ?r npa:isMaintainedBy        ?s .
2317
                  ?s npa:hasMaintainedResource ?r .
2318
                } }
2319
                WHERE {
2320
                  # 1. Anchor: candidate declarations from the extraction graph.
2321
                  GRAPH <%4$s> {
2322
                    ?d a npa:MaintainedResourceDeclaration ;
2323
                       npa:resourceIri     ?r ;
2324
                       npa:maintainerSpace ?s ;
2325
                       npa:pubkeyHash      ?pkh ;
2326
                       npa:viaNanopub      ?np .
2327
                  }
2328
                  # 2. Mirror: resolve ?pkh → ?publisher via the trust-approved row.
2329
                  GRAPH <%3$s> {
2330
                    ?acct a npa:AccountState ;
2331
                          npa:pubkey ?pkh ;
2332
                          npa:agent  ?publisher .
2333
                    # 3. Authority gate (Mode A only): publisher is admin of a ref of the
2334
                    #    maintaining space. ?sRef = that ref (resource → ref edge).
2335
                    ?riA a gen:RoleInstantiation ;
2336
                         npa:inverseProperty gen:hasAdmin ;
2337
                         npa:forSpace ?s ;
2338
                         npa:forSpaceRef ?sRef ;
2339
                         npa:forAgent ?publisher .
2340
                  }
2341
                  # 4. Invalidation filter on the declaration's nanopub.
2342
                  %6$s
2343
                  # 5. Load-number filter on bound ?np.
2344
                  GRAPH <%7$s> {
2345
                    ?np npa:hasLoadNumber ?ln .
2346
                    FILTER (?ln > %5$d)
2347
                  }
2348
                  # 6. Mint the per-(nanopub, resource→ref) provenance link IRI and dedup on
2349
                  #    it (not on the bare edge), so every backing declaration records its own
2350
                  #    removable link; the convenience edges above are re-asserted idempotently.
2351
                  BIND(IRI(CONCAT("http://purl.org/nanopub/admin/spacelink/maintained/",
2352
                                  MD5(CONCAT(STR(?np), "|", STR(?r), "|", STR(?sRef))))) AS ?mrLink)
2353
                  FILTER NOT EXISTS { GRAPH <%3$s> {
2354
                    ?mrLink a npa:MaintainedResourceLink .
2355
                  } }
2356
                }
2357
                """.formatted(
3 ✔
2358
                NPA.NAMESPACE,
2359
                GEN.NAMESPACE,
2360
                graph,
2361
                SpacesVocab.SPACES_GRAPH,
2362
                lastProcessed,
15 ✔
2363
                invalidationFilter("np"),
18 ✔
2364
                NPA.GRAPH);
2365
    }
2366

2367
    /**
2368
     * Space-alias admit pass (issue #113). Copies validated
2369
     * {@code npa:SpaceAliasDeclaration} extraction rows into the space-state graph
2370
     * (preserving the {@code npaalias:} subject) and emits the directional
2371
     * {@code <alias> npa:sameAsSpace <canonical>} edge consumed by the alias-aware
2372
     * admin-authority lookups in {@link #attachmentValidationUpdate},
2373
     * {@link #PUBLISHER_IS_ADMIN}, and {@link #publisherIsTieredRole}.
2374
     *
2375
     * <p>Two gates, both read against the (already-settled) admin closure in the
2376
     * space-state graph:
2377
     * <ul>
2378
     *   <li><b>Authority</b> — the declaration's publisher (resolved via the mirrored
2379
     *       trust-approved {@code AccountState}) is a validated admin of the
2380
     *       <em>canonical</em> space. The alias is declared inside the canonical
2381
     *       space's own {@code gen:Space} nanopub, so this is the same evidence rule
2382
     *       as a {@code gen:hasRole} attachment.</li>
2383
     *   <li><b>Anti-hijack</b> — the alias must not be an independently-governed live
2384
     *       space: it must have no admin who is not also an admin of the canonical
2385
     *       space ({@code admins(alias) ⊆ admins(canonical)}). The common rename case
2386
     *       (the alias's own definition was superseded, so it has no live admin
2387
     *       closure) passes trivially; an attacker publishing
2388
     *       {@code <evil> owl:sameAs <activeSpace>} is rejected because the active
2389
     *       space has admins not in evil's set.</li>
2390
     * </ul>
2391
     *
2392
     * <p>Late-arrival: when the canonical admin grant only becomes valid in the same
2393
     * cycle as the declaration, the load-number filter on {@code ?np} excludes the
2394
     * candidate; the late-arrival sweep ({@link #runDownstreamWithoutLoadFilter})
2395
     * re-runs this pass without the load filter and catches it.
2396
     */
2397
    static String aliasAdmitUpdate(IRI graph, long lastProcessed) {
2398
        // Ref-keyed (see doc/design-spaceref-isolation.md). The declaration names bare
2399
        // canonical/alias IRIs. It is admitted per canonical *ref* whose admin set
2400
        // contains the publisher; the emitted edge is ref-valued on the canonical side
2401
        // (<alias> npa:sameAsSpace <canonicalRef>), which is what the alias-aware admin
2402
        // lookups in the attachment tier consume. Anti-hijack compares the alias IRI's
2403
        // admins against that specific canonical ref's admins — strictly tighter than the
2404
        // old bare-IRI form.
2405
        return """
69 ✔
2406
                PREFIX npa: <%1$s>
2407
                PREFIX gen: <%2$s>
2408
                INSERT { GRAPH <%3$s> {
2409
                  ?d a npa:SpaceAliasDeclaration ;
2410
                     npa:canonicalSpace ?canonical ;
2411
                     npa:aliasSpace     ?alias ;
2412
                     npa:viaNanopub     ?np .
2413
                  ?alias npa:sameAsSpace ?canonRef .
2414
                  # Reified per-(nanopub, alias→canonical ref) provenance link (issue #125
2415
                  # finding #5). The alias edge feeds the admin-authority closure, so this
2416
                  # is the load-bearing case: the cleanup can now drop the edge when its
2417
                  # declaration is invalidated, rather than letting admin authority outlive
2418
                  # a retraction until the next periodic full rebuild.
2419
                  ?alLink a npa:SpaceAliasLink ;
2420
                          npa:viaNanopub        ?np ;
2421
                          npa:aliasSpace        ?alias ;
2422
                          npa:canonicalSpaceRef ?canonRef ;
2423
                          npa:canonicalSpace    ?canonical .
2424
                  # TRANSITIONAL-DUAL-EMIT (Phase 1.5; remove in Phase 4): IRI-valued
2425
                  # alias edge alongside the ref-valued one, so pre-ref published queries
2426
                  # that resolve owl:sameAs by bare canonical IRI keep binding on a
2427
                  # mixed-version fleet. Internal alias-aware lookups (attachment tier)
2428
                  # join through npa:forSpaceRef, which is ref-valued, so this IRI-valued
2429
                  # object never satisfies them — it is inert internally, read-only for
2430
                  # legacy consumers. See doc/report-2026-06-12-mixed-fleet-spaceref-breakage.md.
2431
                  ?alias npa:sameAsSpace ?canonical .
2432
                } }
2433
                WHERE {
2434
                  # 1. Anchor: candidate alias declarations from the extraction graph.
2435
                  GRAPH <%4$s> {
2436
                    ?d a npa:SpaceAliasDeclaration ;
2437
                       npa:canonicalSpace ?canonical ;
2438
                       npa:aliasSpace     ?alias ;
2439
                       npa:pubkeyHash     ?pkh ;
2440
                       npa:viaNanopub     ?np .
2441
                  }
2442
                  # 2. Authority gate per canonical ref: ?canonRef is a ref of ?canonical
2443
                  #    whose admin set contains the declaration's publisher.
2444
                  GRAPH <%4$s> { ?canonRef npa:spaceIri ?canonical . }
2445
                  GRAPH <%3$s> {
2446
                    ?acct a npa:AccountState ;
2447
                          npa:pubkey ?pkh ;
2448
                          npa:agent  ?publisher .
2449
                    ?adminRI a gen:RoleInstantiation ;
2450
                             npa:inverseProperty gen:hasAdmin ;
2451
                             npa:forSpaceRef ?canonRef ;
2452
                             npa:forAgent ?publisher .
2453
                  }
2454
                  # 3. Anti-hijack: the alias IRI must have no admin who is not also an
2455
                  #    admin of this canonical ref (admins(alias) ⊆ admins(canonRef)).
2456
                  FILTER NOT EXISTS {
2457
                    GRAPH <%3$s> {
2458
                      ?aliasAdmin a gen:RoleInstantiation ;
2459
                                  npa:inverseProperty gen:hasAdmin ;
2460
                                  npa:forSpace ?alias ;
2461
                                  npa:forAgent ?otherAgent .
2462
                    }
2463
                    FILTER NOT EXISTS {
2464
                      GRAPH <%3$s> {
2465
                        ?canonAdmin a gen:RoleInstantiation ;
2466
                                    npa:inverseProperty gen:hasAdmin ;
2467
                                    npa:forSpaceRef ?canonRef ;
2468
                                    npa:forAgent ?otherAgent .
2469
                      }
2470
                    }
2471
                  }
2472
                  # 4. Invalidation filter on the declaration's nanopub.
2473
                  %6$s
2474
                  # 5. Load-number filter on bound ?np.
2475
                  GRAPH <%7$s> {
2476
                    ?np npa:hasLoadNumber ?ln .
2477
                    FILTER (?ln > %5$d)
2478
                  }
2479
                  # 6. Mint the per-(nanopub, alias→canonical ref) provenance link IRI and
2480
                  #    dedup on it (not on the bare edge), so every backing declaration records
2481
                  #    its own removable link; the convenience edges above are re-asserted
2482
                  #    idempotently.
2483
                  BIND(IRI(CONCAT("http://purl.org/nanopub/admin/spacelink/alias/",
2484
                                  MD5(CONCAT(STR(?np), "|", STR(?alias), "|", STR(?canonRef))))) AS ?alLink)
2485
                  FILTER NOT EXISTS { GRAPH <%3$s> {
2486
                    ?alLink a npa:SpaceAliasLink .
2487
                  } }
2488
                }
2489
                """.formatted(
3 ✔
2490
                NPA.NAMESPACE,
2491
                GEN.NAMESPACE,
2492
                graph,
2493
                SpacesVocab.SPACES_GRAPH,
2494
                lastProcessed,
15 ✔
2495
                invalidationFilter("np"),
18 ✔
2496
                NPA.GRAPH);
2497
    }
2498

2499
    /**
2500
     * URL-prefix sub-space fallback admit pass. For every pair of {@code SpaceRef}
2501
     * aggregates where the child's {@code npa:hasIdPrefix} matches the parent's
2502
     * {@code npa:spaceIri}, emits convenience {@code <child> npa:isSubSpaceOf <parent>}
2503
     * and {@code <parent> npa:hasSubSpace <child>} direct triples plus a reified
2504
     * {@code npa:DerivedSubSpaceLink} tag carrying {@code npa:derivationKind
2505
     * npa:byUrlPrefix} so consumers can hide derived edges.
2506
     *
2507
     * <p>Per-child suppression: any validated {@code npa:SubSpaceDeclaration} on the
2508
     * child in {@code npass:<…>} suppresses every fallback edge for that child.
2509
     * Suppression checks the validated set (not raw extraction-graph declarations)
2510
     * so an unapproved or in-flight Mode B declaration doesn't silently hide both
2511
     * the URL-prefix fallback and the (still-invalid) explicit relation.
2512
     *
2513
     * <p>Run order: must run after {@link #subSpaceAdmitUpdate} commits in the
2514
     * same cycle so the suppression check sees this cycle's freshly-validated
2515
     * declarations.
2516
     *
2517
     * <p>No load-number filter: the fallback depends on which Spaces exist (parent
2518
     * + child {@code SpaceRef}s), not on which were just added. Always full-scan;
2519
     * the dedup {@code FILTER NOT EXISTS} on the tag IRI prevents re-insertion.
2520
     *
2521
     * <p>No invalidation handling: derived edges have no source nanopub. Two
2522
     * staleness modes: (a) child later gets first validated declaration → old
2523
     * derived edges stay sticky until the next periodic rebuild (same policy as
2524
     * admin-RI invalidation); (b) child loses last validated declaration → the
2525
     * regular fallback pass on the next cycle re-engages, adds derived edges
2526
     * incrementally, no rebuild needed.
2527
     */
2528
    static String subSpacePrefixFallbackUpdate(IRI graph) {
2529
        return """
48 ✔
2530
                PREFIX npa: <%1$s>
2531
                INSERT { GRAPH <%2$s> {
2532
                  ?childRef  npa:isSubSpaceOf ?parentRef .
2533
                  ?parentRef npa:hasSubSpace  ?childRef  .
2534
                  # TRANSITIONAL-DUAL-EMIT (Phase 1.5; remove in Phase 4): IRI-valued
2535
                  # derived sub-space edge alongside the ref-to-ref one, mirroring the
2536
                  # explicit sub-space pass, so pre-ref published queries keep binding on a
2537
                  # mixed-version fleet. See doc/report-2026-06-12-mixed-fleet-spaceref-breakage.md.
2538
                  ?child  npa:isSubSpaceOf ?parent .
2539
                  ?parent npa:hasSubSpace  ?child  .
2540
                  ?tagIri a npa:DerivedSubSpaceLink ;
2541
                          npa:childSpace     ?child ;
2542
                          npa:parentSpace    ?parent ;
2543
                          # Ref endpoints too (issue #125 finding #5), so the sub-space
2544
                          # orphan-sweep recognizes a prefix-derived ref edge as backed and
2545
                          # never deletes it. Derived links have no source nanopub, so they
2546
                          # are never invalidation-deleted; the fallback self-heals each cycle.
2547
                          npa:childSpaceRef  ?childRef ;
2548
                          npa:parentSpaceRef ?parentRef ;
2549
                          npa:derivationKind npa:byUrlPrefix .
2550
                } }
2551
                WHERE {
2552
                  # 1. Anchor: child SpaceRef → its path-prefixes (extracted at load
2553
                  #    time from the Space IRI; see SpacesExtractor.enumerateIdPrefixes).
2554
                  GRAPH <%3$s> {
2555
                    ?childRef  npa:spaceIri    ?child ;
2556
                               npa:hasIdPrefix ?parent .
2557
                    # 2. Parent SpaceRef must exist for the same IRI as the prefix.
2558
                    ?parentRef npa:spaceIri    ?parent .
2559
                  }
2560
                  # 3. Suppress fallback for any child that has a validated declaration
2561
                  #    in this state graph. Per-child IRI, all-or-nothing.
2562
                  FILTER NOT EXISTS {
2563
                    GRAPH <%2$s> {
2564
                      ?d a npa:SubSpaceDeclaration ;
2565
                         npa:childSpace ?child .
2566
                    }
2567
                  }
2568
                  # 4. Mint a deterministic tag IRI per (child ref, parent ref) — the edge
2569
                  #    is emitted ref-to-ref, so the tag and dedup are per ref-pair.
2570
                  BIND(IRI(CONCAT("http://purl.org/nanopub/admin/derivedlink/",
2571
                                  MD5(CONCAT(STR(?childRef), "|", STR(?parentRef))))) AS ?tagIri)
2572
                  # 5. Dedup: don't re-insert if this tag is already present.
2573
                  FILTER NOT EXISTS {
2574
                    GRAPH <%2$s> {
2575
                      ?tagIri a npa:DerivedSubSpaceLink .
2576
                    }
2577
                  }
2578
                }
2579
                """.formatted(
3 ✔
2580
                NPA.NAMESPACE,
2581
                graph,
2582
                SpacesVocab.SPACES_GRAPH);
2583
    }
2584

2585
    /**
2586
     * Reflexive governing-space-ref pass (issue #130). For every {@code SpaceRef}
2587
     * aggregate {@code ?spaceRef} (identified by {@code npa:spaceIri ?space} in the
2588
     * extraction graph), emits {@code <space> npa:hasGoverningSpaceRef <spaceRef>} into
2589
     * the space-state graph — the space pointing at its own ref through the same predicate
2590
     * a maintained resource uses to point at its maintaining space's ref (emitted in
2591
     * {@link #maintainedResourceAdmitUpdate}).
2592
     *
2593
     * <p>This removes the zero-hop special case from consumer authority gates: instead of
2594
     * {@code ?resource npa:isMaintainedBy? ?space} (a bare-IRI optional path that breaks
2595
     * once the hop is ref-valued), a consumer does a single mandatory
2596
     * {@code ?resource npa:hasGoverningSpaceRef ?spaceRef} that binds whether the resource
2597
     * is a maintained resource or a space itself. A space IRI claimed by several refs emits
2598
     * one edge per ref — the non-ref consumer variant's merged-across-refs behaviour falls
2599
     * out naturally; the ref variant pins {@code ?passedRef}.
2600
     *
2601
     * <p>Self-healing, like {@link #subSpacePrefixFallbackUpdate}: the edge has no source
2602
     * nanopub (it follows purely from a {@code SpaceRef} existing), so there is no
2603
     * invalidation handling and no load-number filter — always full-scan, with the dedup
2604
     * {@code FILTER NOT EXISTS} on the edge preventing re-insertion. A {@code SpaceRef}
2605
     * disappearing is itself a structural-rebuild event, which clears its reflexive edge.
2606
     */
2607
    static String governingSpaceRefReflexiveUpdate(IRI graph) {
2608
        return """
48 ✔
2609
                PREFIX npa: <%1$s>
2610
                INSERT { GRAPH <%2$s> {
2611
                  ?space npa:hasGoverningSpaceRef ?spaceRef .
2612
                } }
2613
                WHERE {
2614
                  GRAPH <%3$s> { ?spaceRef npa:spaceIri ?space . }
2615
                  FILTER NOT EXISTS { GRAPH <%2$s> {
2616
                    ?space npa:hasGoverningSpaceRef ?spaceRef .
2617
                  } }
2618
                }
2619
                """.formatted(
3 ✔
2620
                NPA.NAMESPACE,
2621
                graph,
2622
                SpacesVocab.SPACES_GRAPH);
2623
    }
2624

2625
    // ---------------- Invalidation templates (incremental cycle) ----------------
2626

2627
    /**
2628
     * WHERE clause shared by the admin-RI invalidation ASK precheck and the
2629
     * matching DELETE. Identifies admin-tier {@code gen:RoleInstantiation} rows
2630
     * in the space-state graph whose {@code npa:viaNanopub} is the target of an
2631
     * {@code npx:invalidates} triple in {@code npa:graph} whose subject nanopub
2632
     * has a load number in {@code (lastProcessed, ∞)}.
2633
     */
2634
    static String adminInvalidationCheckWhere(IRI graph, long lastProcessed) {
2635
        return String.format("""
60 ✔
2636
                  GRAPH <%1$s> {
2637
                    ?ri a gen:RoleInstantiation ;
2638
                        npa:inverseProperty gen:hasAdmin ;
2639
                        npa:viaNanopub ?np .
2640
                  }
2641
                  GRAPH <%2$s> {
2642
                    ?invNp <%3$s> ?np ;
2643
                           npa:hasLoadNumber ?ln .
2644
                    FILTER (?ln > %4$d)
2645
                    %5$s
2646
                  }
2647
                """, graph, NPA.GRAPH, NPX.INVALIDATES, lastProcessed,
18 ✔
2648
                samePublisherClause("invNp", "np"));
6 ✔
2649
    }
2650

2651
    /** DELETE template for admin-tier RoleInstantiations whose source nanopub was invalidated. */
2652
    static String adminInvalidationDelete(IRI graph, long lastProcessed) {
2653
        return String.format("""
63 ✔
2654
                PREFIX npa: <%1$s>
2655
                PREFIX gen: <%2$s>
2656
                DELETE { GRAPH <%3$s> {
2657
                  ?ri ?p ?o .
2658
                } }
2659
                WHERE {
2660
                  GRAPH <%3$s> { ?ri ?p ?o . }
2661
                %4$s
2662
                }
2663
                """, NPA.NAMESPACE, GEN.NAMESPACE, graph,
2664
                adminInvalidationCheckWhere(graph, lastProcessed));
6 ✔
2665
    }
2666

2667
    /** WHERE clause for RoleAssignment invalidation. */
2668
    static String roleAssignmentInvalidationCheckWhere(IRI graph, long lastProcessed) {
2669
        return String.format("""
60 ✔
2670
                  GRAPH <%1$s> {
2671
                    ?ra a gen:RoleAssignment ;
2672
                        npa:viaNanopub ?np .
2673
                  }
2674
                  GRAPH <%2$s> {
2675
                    ?invNp <%3$s> ?np ;
2676
                           npa:hasLoadNumber ?ln .
2677
                    FILTER (?ln > %4$d)
2678
                    %5$s
2679
                  }
2680
                """, graph, NPA.GRAPH, NPX.INVALIDATES, lastProcessed,
18 ✔
2681
                samePublisherClause("invNp", "np"));
6 ✔
2682
    }
2683

2684
    /** DELETE template for RoleAssignments whose source nanopub was invalidated. */
2685
    static String roleAssignmentInvalidationDelete(IRI graph, long lastProcessed) {
2686
        return String.format("""
63 ✔
2687
                PREFIX npa: <%1$s>
2688
                PREFIX gen: <%2$s>
2689
                DELETE { GRAPH <%3$s> {
2690
                  ?ra ?p ?o .
2691
                } }
2692
                WHERE {
2693
                  GRAPH <%3$s> { ?ra ?p ?o . }
2694
                %4$s
2695
                }
2696
                """, NPA.NAMESPACE, GEN.NAMESPACE, graph,
2697
                roleAssignmentInvalidationCheckWhere(graph, lastProcessed));
6 ✔
2698
    }
2699

2700
    /**
2701
     * DELETE template for non-admin (leaf-tier) RoleInstantiations whose source
2702
     * nanopub was invalidated. Identified as {@code gen:RoleInstantiation} rows
2703
     * lacking the admin-pinning {@code npa:inverseProperty gen:hasAdmin} triple.
2704
     * No flag is set; leaf-tier removals are recoverable on the next cycle.
2705
     */
2706
    static String leafTierInvalidationDelete(IRI graph, long lastProcessed) {
2707
        return String.format("""
84 ✔
2708
                PREFIX npa: <%1$s>
2709
                PREFIX gen: <%2$s>
2710
                DELETE { GRAPH <%3$s> {
2711
                  ?ri ?p ?o .
2712
                } }
2713
                WHERE {
2714
                  GRAPH <%3$s> {
2715
                    ?ri a gen:RoleInstantiation ;
2716
                        npa:viaNanopub ?np .
2717
                    FILTER NOT EXISTS { ?ri npa:inverseProperty gen:hasAdmin }
2718
                    ?ri ?p ?o .
2719
                  }
2720
                  GRAPH <%4$s> {
2721
                    ?invNp <%5$s> ?np ;
2722
                           npa:hasLoadNumber ?ln .
2723
                    FILTER (?ln > %6$d)
2724
                    %7$s
2725
                  }
2726
                }
2727
                """, NPA.NAMESPACE, GEN.NAMESPACE, graph,
2728
                NPA.GRAPH, NPX.INVALIDATES, lastProcessed,
18 ✔
2729
                samePublisherClause("invNp", "np"));
6 ✔
2730
    }
2731

2732
    /**
2733
     * WHERE clause shared by the sub-space invalidation ASK precheck and the
2734
     * matching DELETE. Identifies validated {@code npa:SubSpaceDeclaration} rows
2735
     * in the space-state graph whose {@code npa:viaNanopub} is the target of an
2736
     * {@code npx:invalidates} triple in {@code npa:graph} whose subject nanopub
2737
     * has a load number in {@code (lastProcessed, ∞)}.
2738
     */
2739
    static String subSpaceInvalidationCheckWhere(IRI graph, long lastProcessed) {
2740
        return String.format("""
60 ✔
2741
                  GRAPH <%1$s> {
2742
                    ?d a npa:SubSpaceDeclaration ;
2743
                       npa:viaNanopub ?np .
2744
                  }
2745
                  GRAPH <%2$s> {
2746
                    ?invNp <%3$s> ?np ;
2747
                           npa:hasLoadNumber ?ln .
2748
                    FILTER (?ln > %4$d)
2749
                    %5$s
2750
                  }
2751
                """, graph, NPA.GRAPH, NPX.INVALIDATES, lastProcessed,
18 ✔
2752
                samePublisherClause("invNp", "np"));
6 ✔
2753
    }
2754

2755
    /**
2756
     * DELETE template for validated {@code npa:SubSpaceDeclaration} rows whose
2757
     * source nanopub was invalidated. Removes the per-declaration row by subject;
2758
     * the convenience direct triples ({@code <child> npa:isSubSpaceOf <parent>}
2759
     * and inverse) are then dropped by {@link #subSpaceConvenienceEdgeCleanup} in the
2760
     * same cycle (issue #125 finding #5) once no surviving link backs them.
2761
     */
2762
    static String subSpaceInvalidationDelete(IRI graph, long lastProcessed) {
2763
        return String.format("""
63 ✔
2764
                PREFIX npa: <%1$s>
2765
                PREFIX gen: <%2$s>
2766
                DELETE { GRAPH <%3$s> {
2767
                  ?d ?p ?o .
2768
                } }
2769
                WHERE {
2770
                  GRAPH <%3$s> { ?d ?p ?o . }
2771
                %4$s
2772
                }
2773
                """, NPA.NAMESPACE, GEN.NAMESPACE, graph,
2774
                subSpaceInvalidationCheckWhere(graph, lastProcessed));
6 ✔
2775
    }
2776

2777
    /**
2778
     * DELETE template for validated {@code npa:MaintainedResourceDeclaration} rows
2779
     * whose source nanopub was invalidated. Removes the per-declaration row by
2780
     * subject; the convenience direct triples ({@code <r> npa:isMaintainedBy <s>}
2781
     * and inverse) are then dropped by {@link #maintainedResourceConvenienceEdgeCleanup}
2782
     * in the same cycle (issue #125 finding #5). No structural-rebuild flag —
2783
     * maintained-resource is a leaf relation, no downstream consumers depend on its
2784
     * closure, so the prompt edge cleanup fully resolves its invalidation.
2785
     */
2786
    static String maintainedResourceInvalidationDelete(IRI graph, long lastProcessed) {
2787
        return String.format("""
84 ✔
2788
                PREFIX npa: <%1$s>
2789
                PREFIX gen: <%2$s>
2790
                DELETE { GRAPH <%3$s> {
2791
                  ?d ?p ?o .
2792
                } }
2793
                WHERE {
2794
                  GRAPH <%3$s> {
2795
                    ?d a npa:MaintainedResourceDeclaration ;
2796
                       npa:viaNanopub ?np .
2797
                    ?d ?p ?o .
2798
                  }
2799
                  GRAPH <%4$s> {
2800
                    ?invNp <%5$s> ?np ;
2801
                           npa:hasLoadNumber ?ln .
2802
                    FILTER (?ln > %6$d)
2803
                    %7$s
2804
                  }
2805
                }
2806
                """, NPA.NAMESPACE, GEN.NAMESPACE, graph,
2807
                NPA.GRAPH, NPX.INVALIDATES, lastProcessed,
18 ✔
2808
                samePublisherClause("invNp", "np"));
6 ✔
2809
    }
2810

2811
    /**
2812
     * WHERE clause shared by the alias invalidation ASK precheck and the matching
2813
     * DELETE. Identifies validated {@code npa:SpaceAliasDeclaration} rows in the
2814
     * space-state graph whose {@code npa:viaNanopub} is the target of an
2815
     * {@code npx:invalidates} triple in {@code npa:graph} whose subject nanopub has a
2816
     * load number in {@code (lastProcessed, ∞)}.
2817
     */
2818
    static String aliasInvalidationCheckWhere(IRI graph, long lastProcessed) {
2819
        return String.format("""
60 ✔
2820
                  GRAPH <%1$s> {
2821
                    ?d a npa:SpaceAliasDeclaration ;
2822
                       npa:viaNanopub ?np .
2823
                  }
2824
                  GRAPH <%2$s> {
2825
                    ?invNp <%3$s> ?np ;
2826
                           npa:hasLoadNumber ?ln .
2827
                    FILTER (?ln > %4$d)
2828
                    %5$s
2829
                  }
2830
                """, graph, NPA.GRAPH, NPX.INVALIDATES, lastProcessed,
18 ✔
2831
                samePublisherClause("invNp", "np"));
6 ✔
2832
    }
2833

2834
    /**
2835
     * DELETE template for validated {@code npa:SpaceAliasDeclaration} rows whose
2836
     * source nanopub was invalidated. Removes the per-declaration row by subject; the
2837
     * convenience {@code <alias> npa:sameAsSpace <canonical>} edge is then dropped by
2838
     * {@link #aliasConvenienceEdgeCleanup} in the same cycle (issue #125 finding #5),
2839
     * so an alias can no longer grant admin authority after its declaration is retracted.
2840
     * The alias feeds the authority closure, so this kind is still structural and flips
2841
     * {@code npa:needsFullRebuild} to bound any rows already derived through the edge.
2842
     */
2843
    static String aliasInvalidationDelete(IRI graph, long lastProcessed) {
2844
        return String.format("""
63 ✔
2845
                PREFIX npa: <%1$s>
2846
                PREFIX gen: <%2$s>
2847
                DELETE { GRAPH <%3$s> {
2848
                  ?d ?p ?o .
2849
                } }
2850
                WHERE {
2851
                  GRAPH <%3$s> { ?d ?p ?o . }
2852
                %4$s
2853
                }
2854
                """, NPA.NAMESPACE, GEN.NAMESPACE, graph,
2855
                aliasInvalidationCheckWhere(graph, lastProcessed));
6 ✔
2856
    }
2857

2858
    /**
2859
     * WHERE clause shared by the maintained-resource invalidation ASK precheck and the
2860
     * matching cleanup. Identifies validated {@code npa:MaintainedResourceDeclaration}
2861
     * rows in the space-state graph whose {@code npa:viaNanopub} is the target of an
2862
     * {@code npx:invalidates} triple in {@code npa:graph} whose subject nanopub has a
2863
     * load number in {@code (lastProcessed, ∞)}.
2864
     */
2865
    static String maintainedResourceInvalidationCheckWhere(IRI graph, long lastProcessed) {
2866
        return String.format("""
60 ✔
2867
                  GRAPH <%1$s> {
2868
                    ?d a npa:MaintainedResourceDeclaration ;
2869
                       npa:viaNanopub ?np .
2870
                  }
2871
                  GRAPH <%2$s> {
2872
                    ?invNp <%3$s> ?np ;
2873
                           npa:hasLoadNumber ?ln .
2874
                    FILTER (?ln > %4$d)
2875
                    %5$s
2876
                  }
2877
                """, graph, NPA.GRAPH, NPX.INVALIDATES, lastProcessed,
18 ✔
2878
                samePublisherClause("invNp", "np"));
6 ✔
2879
    }
2880

2881
    /**
2882
     * Convenience-edge cleanup for invalidated sub-space declarations (issue #125
2883
     * finding #5). Run after {@link #subSpaceInvalidationDelete} (which removes the
2884
     * {@code npa:SubSpaceDeclaration} rows). Two phases as one multi-operation update:
2885
     * <ol>
2886
     *   <li>delete every {@code npa:SubSpaceLink} provenance link whose
2887
     *       {@code npa:viaNanopub} was invalidated (same {@code npx:invalidates} +
2888
     *       same-publisher gate as the declaration delete);</li>
2889
     *   <li>orphan-sweep: delete the convenience {@code npa:isSubSpaceOf} /
2890
     *       {@code npa:hasSubSpace} edges (both ref- and IRI-valued) that no surviving
2891
     *       link backs — neither a {@code npa:SubSpaceLink} (explicit declaration) nor a
2892
     *       {@code npa:DerivedSubSpaceLink} (URL-prefix fallback).</li>
2893
     * </ol>
2894
     * Edges backed by another surviving declaration or by the URL-prefix fallback are
2895
     * kept. The {@code npa:needsFullRebuild} flag still fires for the structural kind, so
2896
     * downstream rows derived through a removed edge remain rebuild-bounded; this only
2897
     * stops the convenience edges themselves from going sticky.
2898
     */
2899
    static String subSpaceConvenienceEdgeCleanup(IRI graph, long lastProcessed) {
2900
        return String.format("""
72 ✔
2901
                PREFIX npa: <%1$s>
2902
                # 1. Drop sub-space provenance links whose source nanopub was invalidated.
2903
                DELETE { GRAPH <%2$s> { ?l ?p ?o . } }
2904
                WHERE {
2905
                  GRAPH <%2$s> {
2906
                    ?l a npa:SubSpaceLink ;
2907
                       npa:viaNanopub ?np .
2908
                    ?l ?p ?o .
2909
                  }
2910
                  GRAPH <%3$s> {
2911
                    ?invNp <%4$s> ?np ;
2912
                           npa:hasLoadNumber ?ln .
2913
                    FILTER (?ln > %5$d)
2914
                    %6$s
2915
                  }
2916
                } ;
2917
                # 2. Orphan-sweep isSubSpaceOf edges (ref- and IRI-valued) with no backing link.
2918
                DELETE { GRAPH <%2$s> { ?c npa:isSubSpaceOf ?p . } }
2919
                WHERE {
2920
                  GRAPH <%2$s> {
2921
                    ?c npa:isSubSpaceOf ?p .
2922
                    FILTER NOT EXISTS {
2923
                      { ?l a npa:SubSpaceLink } UNION { ?l a npa:DerivedSubSpaceLink }
2924
                      { { ?l npa:childSpaceRef ?c . ?l npa:parentSpaceRef ?p }
2925
                        UNION
2926
                        { ?l npa:childSpace ?c . ?l npa:parentSpace ?p } }
2927
                    }
2928
                  }
2929
                } ;
2930
                # 3. Orphan-sweep the inverse hasSubSpace edges symmetrically.
2931
                DELETE { GRAPH <%2$s> { ?p npa:hasSubSpace ?c . } }
2932
                WHERE {
2933
                  GRAPH <%2$s> {
2934
                    ?p npa:hasSubSpace ?c .
2935
                    FILTER NOT EXISTS {
2936
                      { ?l a npa:SubSpaceLink } UNION { ?l a npa:DerivedSubSpaceLink }
2937
                      { { ?l npa:childSpaceRef ?c . ?l npa:parentSpaceRef ?p }
2938
                        UNION
2939
                        { ?l npa:childSpace ?c . ?l npa:parentSpace ?p } }
2940
                    }
2941
                  }
2942
                }
2943
                """, NPA.NAMESPACE, graph, NPA.GRAPH, NPX.INVALIDATES, lastProcessed,
18 ✔
2944
                samePublisherClause("invNp", "np"));
6 ✔
2945
    }
2946

2947
    /**
2948
     * Convenience-edge cleanup for invalidated maintained-resource declarations (issue
2949
     * #125 finding #5). Run after {@link #maintainedResourceInvalidationDelete}. Deletes
2950
     * the {@code npa:MaintainedResourceLink} provenance links whose source nanopub was
2951
     * invalidated, then orphan-sweeps the {@code npa:isMaintainedBy} /
2952
     * {@code npa:hasMaintainedResource} edges (ref- and IRI-valued) that no surviving link
2953
     * backs. See {@link #subSpaceConvenienceEdgeCleanup} for the two-phase structure.
2954
     */
2955
    static String maintainedResourceConvenienceEdgeCleanup(IRI graph, long lastProcessed) {
2956
        return String.format("""
72 ✔
2957
                PREFIX npa: <%1$s>
2958
                # 1. Drop maintained-resource provenance links whose source nanopub was invalidated.
2959
                DELETE { GRAPH <%2$s> { ?l ?p ?o . } }
2960
                WHERE {
2961
                  GRAPH <%2$s> {
2962
                    ?l a npa:MaintainedResourceLink ;
2963
                       npa:viaNanopub ?np .
2964
                    ?l ?p ?o .
2965
                  }
2966
                  GRAPH <%3$s> {
2967
                    ?invNp <%4$s> ?np ;
2968
                           npa:hasLoadNumber ?ln .
2969
                    FILTER (?ln > %5$d)
2970
                    %6$s
2971
                  }
2972
                } ;
2973
                # 2. Orphan-sweep isMaintainedBy edges (ref- and IRI-valued) with no backing link.
2974
                DELETE { GRAPH <%2$s> { ?r npa:isMaintainedBy ?o . } }
2975
                WHERE {
2976
                  GRAPH <%2$s> {
2977
                    ?r npa:isMaintainedBy ?o .
2978
                    FILTER NOT EXISTS {
2979
                      ?l a npa:MaintainedResourceLink ;
2980
                         npa:resourceIri ?r .
2981
                      { ?l npa:maintainerSpaceRef ?o } UNION { ?l npa:maintainerSpace ?o }
2982
                    }
2983
                  }
2984
                } ;
2985
                # 3. Orphan-sweep the inverse hasMaintainedResource edges symmetrically.
2986
                DELETE { GRAPH <%2$s> { ?o npa:hasMaintainedResource ?r . } }
2987
                WHERE {
2988
                  GRAPH <%2$s> {
2989
                    ?o npa:hasMaintainedResource ?r .
2990
                    FILTER NOT EXISTS {
2991
                      ?l a npa:MaintainedResourceLink ;
2992
                         npa:resourceIri ?r .
2993
                      { ?l npa:maintainerSpaceRef ?o } UNION { ?l npa:maintainerSpace ?o }
2994
                    }
2995
                  }
2996
                } ;
2997
                # 4. Orphan-sweep the maintained arm of hasGoverningSpaceRef (issue #130).
2998
                #    Only the ref-valued maintained edge is removed here — it is backed by a
2999
                #    MaintainedResourceLink. The reflexive space self-edge (subject = a space
3000
                #    IRI that has its own SpaceRef) is NOT a maintained edge and is left to the
3001
                #    self-healing reflexive pass, so the guard keeps any ?r that is itself a space.
3002
                DELETE { GRAPH <%2$s> { ?r npa:hasGoverningSpaceRef ?o . } }
3003
                WHERE {
3004
                  GRAPH <%2$s> {
3005
                    ?r npa:hasGoverningSpaceRef ?o .
3006
                    FILTER NOT EXISTS {
3007
                      ?l a npa:MaintainedResourceLink ;
3008
                         npa:resourceIri ?r ;
3009
                         npa:maintainerSpaceRef ?o .
3010
                    }
3011
                    FILTER NOT EXISTS { GRAPH <%7$s> { ?o npa:spaceIri ?r . } }
3012
                  }
3013
                }
3014
                """, NPA.NAMESPACE, graph, NPA.GRAPH, NPX.INVALIDATES, lastProcessed,
18 ✔
3015
                samePublisherClause("invNp", "np"), SpacesVocab.SPACES_GRAPH);
18 ✔
3016
    }
3017

3018
    /**
3019
     * Convenience-edge cleanup for invalidated space-alias declarations (issue #125
3020
     * finding #5 — the load-bearing case, since the alias edge feeds the admin-authority
3021
     * closure). Run after {@link #aliasInvalidationDelete}. Deletes the
3022
     * {@code npa:SpaceAliasLink} provenance links whose source nanopub was invalidated,
3023
     * then orphan-sweeps the {@code npa:sameAsSpace} edges (ref- and IRI-valued) that no
3024
     * surviving link backs. See {@link #subSpaceConvenienceEdgeCleanup} for the two-phase
3025
     * structure.
3026
     */
3027
    static String aliasConvenienceEdgeCleanup(IRI graph, long lastProcessed) {
3028
        return String.format("""
72 ✔
3029
                PREFIX npa: <%1$s>
3030
                # 1. Drop alias provenance links whose source nanopub was invalidated.
3031
                DELETE { GRAPH <%2$s> { ?l ?p ?o . } }
3032
                WHERE {
3033
                  GRAPH <%2$s> {
3034
                    ?l a npa:SpaceAliasLink ;
3035
                       npa:viaNanopub ?np .
3036
                    ?l ?p ?o .
3037
                  }
3038
                  GRAPH <%3$s> {
3039
                    ?invNp <%4$s> ?np ;
3040
                           npa:hasLoadNumber ?ln .
3041
                    FILTER (?ln > %5$d)
3042
                    %6$s
3043
                  }
3044
                } ;
3045
                # 2. Orphan-sweep sameAsSpace edges (ref- and IRI-valued) with no backing link.
3046
                DELETE { GRAPH <%2$s> { ?alias npa:sameAsSpace ?o . } }
3047
                WHERE {
3048
                  GRAPH <%2$s> {
3049
                    ?alias npa:sameAsSpace ?o .
3050
                    FILTER NOT EXISTS {
3051
                      ?l a npa:SpaceAliasLink ;
3052
                         npa:aliasSpace ?alias .
3053
                      { ?l npa:canonicalSpaceRef ?o } UNION { ?l npa:canonicalSpace ?o }
3054
                    }
3055
                  }
3056
                }
3057
                """, NPA.NAMESPACE, graph, NPA.GRAPH, NPX.INVALIDATES, lastProcessed,
18 ✔
3058
                samePublisherClause("invNp", "np"));
6 ✔
3059
    }
3060

3061
    /**
3062
     * WHERE clause shared by the preset-deactivation ASK precheck and the matching DELETE
3063
     * (Nanodash issue #302). Binds {@code ?ra} = a materialized preset-derived
3064
     * {@code gen:RoleAssignment} ({@code npa:derivedFromPreset}) for which a <em>newer,
3065
     * admin-authored</em> same-{@code (preset, resource)} assignment exists by
3066
     * {@code dct:created} (load number in {@code (lastProcessed, ∞)}). This is NOT an
3067
     * {@code npx:invalidates} check — preset activation is latest-wins by timestamp.
3068
     *
3069
     * <p>Authorization-scoped (anti-hijack, design doc §3/§4.4): the newer assignment's
3070
     * publisher must itself be a validated admin of the row's {@code npa:forSpaceRef}, so an
3071
     * unauthorized key's newer assignment can neither delete nor shadow an admin's
3072
     * materialized role. {@code dct:created} is written as a full IRI (not a {@code dct:}
3073
     * prefix) because {@link #wouldInvalidate}'s ASK wrapper only declares {@code npa:} /
3074
     * {@code gen:}.
3075
     */
3076
    static String presetDeactivationCheckWhere(IRI graph, long lastProcessed) {
3077
        return String.format("""
60 ✔
3078
                  GRAPH <%1$s> {
3079
                    ?ra a gen:RoleAssignment ;
3080
                        npa:derivedFromPreset ?assignNp ;
3081
                        npa:forSpaceRef ?targetRef .
3082
                  }
3083
                  GRAPH <%2$s> {
3084
                    ?pa a npa:PresetAssignment ;
3085
                        npa:viaNanopub  ?assignNp ;
3086
                        npa:ofPreset    ?preset ;
3087
                        npa:forResource ?resource ;
3088
                        <http://purl.org/dc/terms/created> ?created .
3089
                    ?paNewer a npa:PresetAssignment ;
3090
                             npa:ofPreset    ?preset ;
3091
                             npa:forResource ?resource ;
3092
                             npa:pubkeyHash  ?pkhNewer ;
3093
                             npa:viaNanopub  ?assignNpNewer ;
3094
                             <http://purl.org/dc/terms/created> ?createdNewer .
3095
                    FILTER (?createdNewer > ?created
3096
                            || (?createdNewer = ?created && STR(?paNewer) > STR(?pa)))
3097
                  }
3098
                  GRAPH <%3$s> {
3099
                    ?assignNpNewer npa:hasLoadNumber ?lnNewer .
3100
                    FILTER (?lnNewer > %4$d)
3101
                  }
3102
                  GRAPH <%1$s> {
3103
                    ?acctNewer a npa:AccountState ;
3104
                               npa:agent  ?publisherNewer ;
3105
                               npa:pubkey ?pkhNewer .
3106
                    ?adminRINewer a gen:RoleInstantiation ;
3107
                                  npa:forSpaceRef ?targetRef ;
3108
                                  npa:inverseProperty gen:hasAdmin ;
3109
                                  npa:forAgent ?publisherNewer .
3110
                  }
3111
                """, graph, SpacesVocab.SPACES_GRAPH, NPA.GRAPH, lastProcessed);
6 ✔
3112
    }
3113

3114
    /**
3115
     * DELETE template for preset-derived {@code gen:RoleAssignment} rows superseded by a
3116
     * newer admin-authored same-pair assignment (issue #302). Removes the whole row by
3117
     * subject; scoped via {@code npa:derivedFromPreset} so directly-published attachments
3118
     * are never touched. The {@link #presetAttachmentValidationUpdate} re-INSERT in the
3119
     * same cycle re-materializes the pair iff the newest assignment is still active.
3120
     */
3121
    static String presetDeactivationDelete(IRI graph, long lastProcessed) {
3122
        return String.format("""
63 ✔
3123
                PREFIX npa: <%1$s>
3124
                PREFIX gen: <%2$s>
3125
                DELETE { GRAPH <%3$s> {
3126
                  ?ra ?p ?o .
3127
                } }
3128
                WHERE {
3129
                  GRAPH <%3$s> { ?ra ?p ?o . }
3130
                %4$s
3131
                }
3132
                """, NPA.NAMESPACE, GEN.NAMESPACE, graph,
3133
                presetDeactivationCheckWhere(graph, lastProcessed));
6 ✔
3134
    }
3135

3136
    /**
3137
     * WHERE clause matching a materialized <em>non-admin</em> {@code gen:RoleInstantiation}
3138
     * row whose {@code (forSpaceRef, forAgent, gen:hasRole)} key is shadowed by a newer
3139
     * authorized {@code npa:RoleRevocation} (issue #129). The grant timestamp comes from the
3140
     * originating instantiation in the extraction graph (the materialized row carries no
3141
     * {@code dct:created}); the revocation nanopub's load number must be in
3142
     * {@code (lastProcessed, ∞)} so only revocations new in this cycle trigger a delete.
3143
     * Authorization is keyed on the row's bound {@code ?tier} (the matrix: a strictly-higher
3144
     * tier in the ref, or self). Not an {@code npx:invalidates} check.
3145
     */
3146
    static String roleRevocationCheckWhere(IRI graph, long lastProcessed, IRI targetTier) {
3147
        return String.format("""
60 ✔
3148
                  GRAPH <%1$s> {
3149
                    ?ri2 a gen:RoleInstantiation ;
3150
                         npa:forSpaceRef ?spaceRef ;
3151
                         npa:forAgent    ?agent ;
3152
                         gen:hasRole     ?role ;
3153
                         npa:hasRoleType <%7$s> ;
3154
                         npa:viaNanopub  ?np .
3155
                  }
3156
                  OPTIONAL { GRAPH <%2$s> {
3157
                    ?riSrc npa:viaNanopub ?np ;
3158
                           <http://purl.org/dc/terms/created> ?candCreatedRaw .
3159
                  } }
3160
                  BIND(COALESCE(?candCreatedRaw, %5$s) AS ?candCreated)
3161
                  { GRAPH <%2$s> { ?spaceRef npa:spaceIri ?revSpace . } }
3162
                  UNION
3163
                  { GRAPH <%1$s> { ?revSpace npa:sameAsSpace ?spaceRef . } }
3164
                  GRAPH <%2$s> {
3165
                    ?rev a npa:RoleRevocation ;
3166
                         npa:forSpace    ?revSpace ;
3167
                         npa:forAgent    ?agent ;
3168
                         npa:revokedRole ?role ;
3169
                         npa:pubkeyHash  ?revPkh ;
3170
                         npa:viaNanopub  ?revNp .
3171
                    OPTIONAL { ?rev <http://purl.org/dc/terms/created> ?revCreatedRaw . }
3172
                  }
3173
                  BIND(COALESCE(?revCreatedRaw, %5$s) AS ?revCreated)
3174
                  GRAPH <%3$s> {
3175
                    ?revNp npa:hasLoadNumber ?lnRev .
3176
                    FILTER (?lnRev > %4$d)
3177
                  }
3178
                  FILTER (?revCreated > ?candCreated
3179
                          || (?revCreated = ?candCreated && STR(?rev) > STR(?ri2)))
3180
                  { %6$s }
3181
                """, graph, SpacesVocab.SPACES_GRAPH, NPA.GRAPH, lastProcessed,
30 ✔
3182
                EPOCH_DT, revocationAuthorityArmsForTier(graph, targetTier), targetTier);
18 ✔
3183
    }
3184

3185
    /**
3186
     * DELETE template removing a non-admin {@code gen:RoleInstantiation} row of {@code
3187
     * targetTier} shadowed by a newer authorized revocation (issue #129). Removes the whole
3188
     * row by subject. Run once per non-admin tier (maintainer/member/observer) so the
3189
     * authorization arms are the compile-time set for that tier — matching the inline
3190
     * suppression filter, no runtime {@code ?tier} (see {@link #revocationAuthorityArmsForTier}).
3191
     * Caller sets {@code needsFullRebuild} (a revoked maintainer/member is a sub-granting
3192
     * authority).
3193
     */
3194
    static String roleRevocationDelete(IRI graph, long lastProcessed, IRI targetTier) {
3195
        return String.format("""
66 ✔
3196
                PREFIX npa: <%1$s>
3197
                PREFIX gen: <%2$s>
3198
                DELETE { GRAPH <%3$s> {
3199
                  ?ri2 ?p ?o .
3200
                } }
3201
                WHERE {
3202
                  GRAPH <%3$s> { ?ri2 ?p ?o . }
3203
                %4$s
3204
                }
3205
                """, NPA.NAMESPACE, GEN.NAMESPACE, graph,
3206
                roleRevocationCheckWhere(graph, lastProcessed, targetTier));
6 ✔
3207
    }
3208

3209
    /**
3210
     * WHERE clause matching a materialized <em>admin</em> {@code gen:RoleInstantiation} row
3211
     * whose {@code (forSpaceRef, forAgent)} key is shadowed by a newer authorized admin
3212
     * {@code npa:RoleRevocation} ({@code revokedRole = gen:AdminRole}), authorized by an
3213
     * admin of the ref or by the agent itself. <b>Root admins are exempt</b> (constitutional,
3214
     * issue #129/#110): the nested {@code FILTER NOT EXISTS} on {@code npa:hasRootAdmin}
3215
     * makes the revocation inert. The revocation nanopub's load number must be in
3216
     * {@code (lastProcessed, ∞)}.
3217
     */
3218
    static String adminRevocationCheckWhere(IRI graph, long lastProcessed) {
3219
        return String.format("""
60 ✔
3220
                  GRAPH <%1$s> {
3221
                    ?sri a gen:RoleInstantiation ;
3222
                         npa:forSpaceRef     ?spaceRef ;
3223
                         npa:inverseProperty gen:hasAdmin ;
3224
                         npa:forAgent        ?agent ;
3225
                         npa:viaNanopub      ?np .
3226
                  }
3227
                  FILTER NOT EXISTS { GRAPH <%2$s> {
3228
                    ?rootDef a npa:SpaceDefinition ;
3229
                             npa:forSpaceRef  ?spaceRef ;
3230
                             npa:hasRootAdmin ?agent .
3231
                  } }
3232
                  OPTIONAL { GRAPH <%2$s> {
3233
                    ?riSrc npa:viaNanopub ?np ;
3234
                           <http://purl.org/dc/terms/created> ?candCreatedRaw .
3235
                  } }
3236
                  BIND(COALESCE(?candCreatedRaw, %5$s) AS ?candCreated)
3237
                  { GRAPH <%2$s> { ?spaceRef npa:spaceIri ?revSpace . } }
3238
                  UNION
3239
                  { GRAPH <%1$s> { ?revSpace npa:sameAsSpace ?spaceRef . } }
3240
                  GRAPH <%2$s> {
3241
                    ?rev a npa:RoleRevocation ;
3242
                         npa:forSpace    ?revSpace ;
3243
                         npa:forAgent    ?agent ;
3244
                         npa:revokedRole gen:AdminRole ;
3245
                         npa:pubkeyHash  ?revPkh ;
3246
                         npa:viaNanopub  ?revNp .
3247
                    OPTIONAL { ?rev <http://purl.org/dc/terms/created> ?revCreatedRaw . }
3248
                  }
3249
                  BIND(COALESCE(?revCreatedRaw, %5$s) AS ?revCreated)
3250
                  GRAPH <%3$s> {
3251
                    ?revNp npa:hasLoadNumber ?lnRev .
3252
                    FILTER (?lnRev > %4$d)
3253
                  }
3254
                  FILTER (?revCreated > ?candCreated
3255
                          || (?revCreated = ?candCreated && STR(?rev) > STR(?sri)))
3256
                  { %6$s }
3257
                """, graph, SpacesVocab.SPACES_GRAPH, NPA.GRAPH, lastProcessed, EPOCH_DT,
27 ✔
3258
                "{ " + revokerAdminGraphBlock(graph) + " }\nUNION\n{ "
6 ✔
3259
                        + revokerSelfGraphBlock(graph) + " }");
9 ✔
3260
    }
3261

3262
    /**
3263
     * DELETE template removing an admin {@code gen:RoleInstantiation} row shadowed by a newer
3264
     * authorized admin revocation (issue #129). Removes the whole row by subject.
3265
     * <b>Structural</b> — admin RIs feed every downstream tier — so the caller sets
3266
     * {@code npa:needsFullRebuild} (mirrors {@code adminInvalidationDelete}). The
3267
     * {@code adminTierUpdate} inline suppression filter prevents re-materialization.
3268
     */
3269
    static String adminRevocationDelete(IRI graph, long lastProcessed) {
3270
        return String.format("""
63 ✔
3271
                PREFIX npa: <%1$s>
3272
                PREFIX gen: <%2$s>
3273
                DELETE { GRAPH <%3$s> {
3274
                  ?sri ?p ?o .
3275
                } }
3276
                WHERE {
3277
                  GRAPH <%3$s> { ?sri ?p ?o . }
3278
                %4$s
3279
                }
3280
                """, NPA.NAMESPACE, GEN.NAMESPACE, graph,
3281
                adminRevocationCheckWhere(graph, lastProcessed));
6 ✔
3282
    }
3283

3284
    /**
3285
     * WHERE clause matching a materialized {@code gen:RoleAssignment} row (direct
3286
     * <em>or</em> preset-derived) whose {@code (forSpaceRef, gen:hasRole)} key is shadowed by
3287
     * a newer admin-authored {@code npa:RoleDetachment} (issue #129). The attachment
3288
     * timestamp comes from whichever extraction row shares the materialized row's
3289
     * {@code npa:viaNanopub} (a {@code RoleAssignment} for direct attachments, a
3290
     * {@code PresetAssignment} for preset-derived ones). The detachment nanopub's load number
3291
     * must be in {@code (lastProcessed, ∞)}; authority = admin of the ref.
3292
     */
3293
    static String roleDetachmentCheckWhere(IRI graph, long lastProcessed) {
3294
        return String.format("""
60 ✔
3295
                  GRAPH <%1$s> {
3296
                    ?ra2 a gen:RoleAssignment ;
3297
                         npa:forSpaceRef ?targetRef ;
3298
                         gen:hasRole     ?role ;
3299
                         npa:viaNanopub  ?np .
3300
                  }
3301
                  OPTIONAL { GRAPH <%2$s> {
3302
                    ?attSrc npa:viaNanopub ?np ;
3303
                            <http://purl.org/dc/terms/created> ?attCreatedRaw .
3304
                  } }
3305
                  BIND(COALESCE(?attCreatedRaw, %5$s) AS ?attCreated)
3306
                  { GRAPH <%2$s> { ?targetRef npa:spaceIri ?detSpace . } }
3307
                  UNION
3308
                  { GRAPH <%1$s> { ?detSpace npa:sameAsSpace ?targetRef . } }
3309
                  GRAPH <%2$s> {
3310
                    ?det a npa:RoleDetachment ;
3311
                         npa:forSpace    ?detSpace ;
3312
                         npa:revokedRole ?role ;
3313
                         npa:pubkeyHash  ?detPkh ;
3314
                         npa:viaNanopub  ?detNp .
3315
                    OPTIONAL { ?det <http://purl.org/dc/terms/created> ?detCreatedRaw . }
3316
                  }
3317
                  BIND(COALESCE(?detCreatedRaw, %5$s) AS ?detCreated)
3318
                  GRAPH <%3$s> {
3319
                    ?detNp npa:hasLoadNumber ?lnDet .
3320
                    FILTER (?lnDet > %4$d)
3321
                  }
3322
                  FILTER (?detCreated > ?attCreated
3323
                          || (?detCreated = ?attCreated && STR(?det) > STR(?ra2)))
3324
                  GRAPH <%1$s> {
3325
                    ?detAcct a npa:AccountState ; npa:pubkey ?detPkh ; npa:agent ?detAgent .
3326
                    ?detAdminRI a gen:RoleInstantiation ;
3327
                                npa:forSpaceRef ?targetRef ;
3328
                                npa:inverseProperty gen:hasAdmin ;
3329
                                npa:forAgent ?detAgent .
3330
                  }
3331
                """, graph, SpacesVocab.SPACES_GRAPH, NPA.GRAPH, lastProcessed, EPOCH_DT);
18 ✔
3332
    }
3333

3334
    /**
3335
     * DELETE template removing a {@code gen:RoleAssignment} row (direct or preset-derived)
3336
     * shadowed by a newer admin-authored {@code gen:detachedRole} (issue #129). Removes the
3337
     * whole row by subject. <b>Structural</b> — instantiations anchored on the removed
3338
     * attachment are bounded by the periodic full rebuild (the cascade), so the caller sets
3339
     * {@code npa:needsFullRebuild}. The attachment-tier inline filters prevent
3340
     * re-materialization until a newer attachment / preset assignment out-ranks the detach
3341
     * (non-sticky).
3342
     */
3343
    static String roleDetachmentDelete(IRI graph, long lastProcessed) {
3344
        return String.format("""
63 ✔
3345
                PREFIX npa: <%1$s>
3346
                PREFIX gen: <%2$s>
3347
                DELETE { GRAPH <%3$s> {
3348
                  ?ra2 ?p ?o .
3349
                } }
3350
                WHERE {
3351
                  GRAPH <%3$s> { ?ra2 ?p ?o . }
3352
                %4$s
3353
                }
3354
                """, NPA.NAMESPACE, GEN.NAMESPACE, graph,
3355
                roleDetachmentCheckWhere(graph, lastProcessed));
6 ✔
3356
    }
3357

3358
    /**
3359
     * DELETE template for ref-scoped preset-assignment stamps ({@link
3360
     * #presetAssignmentRefStampUpdate}) whose underlying assignment nanopub was
3361
     * hard-retracted (issue #122). Removes the whole row by subject; scoped to
3362
     * state-graph {@code npa:PresetAssignment} rows that carry {@code npa:forSpaceRef}
3363
     * (the IRI-keyed extraction rows never do), so it can never touch them.
3364
     *
3365
     * <p>Leaf delete — no structural flag: nothing downstream derives from a listing
3366
     * stamp, so a stale row only mis-displays a retracted assignment until this cycle's
3367
     * delete runs. Admin-grant revocation is bounded by the periodic full rebuild (same
3368
     * sticky-convenience policy as the alias / sub-space declaration edges). A
3369
     * <em>deactivation</em> needs no delete here: it is represented as a newer
3370
     * admin-authored stamp with {@code npa:isActivated false}, resolved by the consumer's
3371
     * latest-wins.
3372
     */
3373
    static String presetAssignmentRefInvalidationDelete(IRI graph, long lastProcessed) {
3374
        return String.format("""
84 ✔
3375
                PREFIX npa: <%1$s>
3376
                PREFIX gen: <%2$s>
3377
                DELETE { GRAPH <%3$s> {
3378
                  ?paRef ?p ?o .
3379
                } }
3380
                WHERE {
3381
                  GRAPH <%3$s> {
3382
                    ?paRef a npa:PresetAssignment ;
3383
                           npa:forSpaceRef ?targetRef ;
3384
                           npa:viaNanopub  ?assignNp .
3385
                    ?paRef ?p ?o .
3386
                  }
3387
                  GRAPH <%4$s> {
3388
                    ?invNp <%5$s> ?assignNp ;
3389
                           npa:hasLoadNumber ?ln .
3390
                    FILTER (?ln > %6$d)
3391
                    %7$s
3392
                  }
3393
                }
3394
                """, NPA.NAMESPACE, GEN.NAMESPACE, graph,
3395
                NPA.GRAPH, NPX.INVALIDATES, lastProcessed,
18 ✔
3396
                samePublisherClause("invNp", "assignNp"));
6 ✔
3397
    }
3398

3399
    /** Wraps an ASK by joining the shared prefixes. */
3400
    private boolean wouldInvalidate(IRI graph, long lastProcessed,
3401
                                    boolean adminPinned, String whereClause) {
3402
        // adminPinned is informational only — kept to make call sites read clearly;
3403
        // the WHERE clause already encodes the kind via its own type predicates.
3404
        String ask = String.format("""
51 ✔
3405
                PREFIX npa: <%1$s>
3406
                PREFIX gen: <%2$s>
3407
                ASK { %3$s }
3408
                """, NPA.NAMESPACE, GEN.NAMESPACE, whereClause);
3409
        return runAsk(ask);
12 ✔
3410
    }
3411

3412
    private boolean runAsk(String sparql) {
3413
        try (RepositoryConnection conn = TripleStore.get().getRepoConnection(SPACES_REPO)) {
12 ✔
3414
            BooleanQuery query = conn.prepareBooleanQuery(QueryLanguage.SPARQL, sparql);
15 ✔
3415
            query.setMaxExecutionTime(SERVER_TIME_LIMIT_SECONDS);
9 ✔
3416
            return query.evaluate();
15 ✔
3417
        }
3418
    }
3419

3420
    private void executeUpdate(String sparqlUpdate) {
3421
        try (RepositoryConnection conn = TripleStore.get().getRepoConnection(SPACES_REPO)) {
12 ✔
3422
            Update update = conn.prepareUpdate(QueryLanguage.SPARQL, sparqlUpdate);
15 ✔
3423
            update.setMaxExecutionTime(SERVER_TIME_LIMIT_SECONDS);
9 ✔
3424
            update.execute();
6 ✔
3425
        }
3426
    }
3 ✔
3427

3428
    // ---------------- Mirror step ----------------
3429

3430
    /**
3431
     * Copies trust-approved {@code npa:AccountState} rows from {@code npat:<T>}
3432
     * in the {@code trust} repo into {@code newGraph} in the {@code spaces} repo,
3433
     * inside one spaces-side serializable transaction.
3434
     *
3435
     * @return number of rows mirrored (useful for metrics / logging)
3436
     */
3437
    /**
3438
     * Whether the given trust state's graph holds anything at all.
3439
     *
3440
     * <p>Used by {@link #runFullBuild} to tell a build that read nothing because the store
3441
     * would not answer from a build that read nothing because there is nothing to read. Only
3442
     * the first is a reason to withhold the result; withholding the second would freeze a
3443
     * stale space state in place, and stale trust data is over-permissive.
3444
     *
3445
     * <p>Throws rather than guessing if the trust repo cannot be read — {@link #runFullBuild}
3446
     * then aborts without publishing or dropping anything, which is the safe direction.
3447
     *
3448
     * @param trustStateHash the trust state hash
3449
     * @return true if the trust state graph contains at least one triple
3450
     */
3451
    boolean trustStateHasContent(String trustStateHash) {
3452
        IRI trustStateIri = NPAT.forHash(trustStateHash);
×
3453
        try (RepositoryConnection conn = TripleStore.get().getRepoConnection(TRUST_REPO)) {
×
3454
            String query = String.format("ASK { GRAPH <%s> { ?s ?p ?o } }", trustStateIri);
×
3455
            return conn.prepareBooleanQuery(QueryLanguage.SPARQL, query).evaluate();
×
3456
        } catch (Exception ex) {
×
3457
            throw new SpaceStateUnavailableException(
×
3458
                    "failed to read trust state graph " + trustStateIri, ex);
3459
        }
3460
    }
3461

3462
    /**
3463
     * First object of {@code (subject, predicate)} in {@code context}, or {@code null}.
3464
     *
3465
     * <p>Closes the underlying {@link RepositoryResult}: the {@code .stream().findFirst()}
3466
     * shorthand this replaces leaves the iteration open, which RDF4J reports as
3467
     * "Connection closed before all iterations were closed" whenever the cleaner has not
3468
     * collected it before the connection closes.
3469
     */
3470
    private static Value firstObject(RepositoryConnection conn, IRI subject, IRI predicate, IRI context) {
3471
        try (RepositoryResult<Statement> r = conn.getStatements(subject, predicate, null, context)) {
36 ✔
3472
            return r.hasNext() ? r.next().getObject() : null;
36 ✔
3473
        }
3474
    }
3475

3476
    int mirrorTrustState(String trustStateHash, IRI newGraph) {
3477
        IRI trustStateIri = NPAT.forHash(trustStateHash);
9 ✔
3478
        int count = 0;
6 ✔
3479
        try (RepositoryConnection trustConn = TripleStore.get().getRepoConnection(TRUST_REPO);
12 ✔
3480
             RepositoryConnection spacesConn = TripleStore.get().getRepoConnection(SPACES_REPO)) {
12 ✔
3481
            trustConn.begin(IsolationLevels.READ_COMMITTED);
9 ✔
3482
            // Append-only writes into the not-yet-published newGraph (the current-state
3483
            // pointer is swapped to it only after the build completes), and all spaces
3484
            // writers serialise via this class's synchronized methods; see
3485
            // NanopubLoader#repoWriteLocks for why SERIALIZABLE is avoided.
3486
            spacesConn.begin(IsolationLevels.READ_COMMITTED);
9 ✔
3487
            // Walk rdf:type triples in the trust state's graph; for each AccountState,
3488
            // check status and copy the approved ones verbatim (minus status-specific
3489
            // detail triples, which we don't need for validation).
3490
            try (RepositoryResult<Statement> typeRows = trustConn.getStatements(
36 ✔
3491
                    null, RDF.TYPE, NPA_ACCOUNT_STATE, trustStateIri)) {
3492
                while (typeRows.hasNext()) {
9 ✔
3493
                    Statement st = typeRows.next();
12 ✔
3494
                    if (!(st.getSubject() instanceof IRI accountStateIri)) continue;
27 !
3495
                    Value status = firstObject(trustConn, accountStateIri, NPA_TRUST_STATUS, trustStateIri);
18 ✔
3496
                    if (!(status instanceof IRI statusIri) || !APPROVED_SET.contains(statusIri)) continue;
33 !
3497
                    Value agent = firstObject(trustConn, accountStateIri, NPA_AGENT, trustStateIri);
18 ✔
3498
                    Value pubkey = firstObject(trustConn, accountStateIri, NPA_PUBKEY, trustStateIri);
18 ✔
3499
                    if (agent == null || pubkey == null) {
12 ✔
3500
                        logger.warn("AuthorityResolver.mirror: account {} missing agent or pubkey; skipping",
12 ✔
3501
                                accountStateIri);
3502
                        continue;
3 ✔
3503
                    }
3504
                    spacesConn.add(accountStateIri, RDF.TYPE, NPA_ACCOUNT_STATE, newGraph);
33 ✔
3505
                    spacesConn.add(accountStateIri, NPA_AGENT, agent, newGraph);
33 ✔
3506
                    spacesConn.add(accountStateIri, NPA_PUBKEY, pubkey, newGraph);
33 ✔
3507
                    spacesConn.add(accountStateIri, NPA_TRUST_STATUS, statusIri, newGraph);
33 ✔
3508
                    // Mirror the authorizing introduction provenance when present (issue #125
3509
                    // finding #4). Optional: absent for snapshots from registries that predate
3510
                    // nanopub-registry#117/#118, so consumers (e.g. get-space-members-ref) must
3511
                    // treat npa:viaNanopub on an AccountState as best-effort, not guaranteed.
3512
                    Value viaNanopub = firstObject(trustConn, accountStateIri, NPA_VIA_NANOPUB, trustStateIri);
18 ✔
3513
                    if (viaNanopub != null) {
6 ✔
3514
                        spacesConn.add(accountStateIri, NPA_VIA_NANOPUB, viaNanopub, newGraph);
33 ✔
3515
                    }
3516
                    count++;
3 ✔
3517
                }
3 ✔
3518
            }
3519
            // Mirror canonical foaf:name triples for approved agents. The trust
3520
            // loader emits one per agent (across approved keys, MAX(ratio) wins).
3521
            // Copying them into the space-state graph means consumers reading
3522
            // ?agent foaf:name ?n inside the state graph hit local data, with no
3523
            // cross-repo SERVICE.
3524
            try (RepositoryResult<Statement> nameRows = trustConn.getStatements(
36 ✔
3525
                    null, FOAF.NAME, null, trustStateIri)) {
3526
                while (nameRows.hasNext()) {
9 ✔
3527
                    Statement st = nameRows.next();
12 ✔
3528
                    spacesConn.add(st.getSubject(), st.getPredicate(), st.getObject(), newGraph);
42 ✔
3529
                }
3 ✔
3530
            }
3531
            spacesConn.commit();
6 ✔
3532
            trustConn.commit();
6 ✔
3533
        }
3534
        return count;
6 ✔
3535
    }
3536

3537
    // ---------------- Pending-account mirror (issue #195) ----------------
3538

3539
    /**
3540
     * Result of one {@link #mirrorPendingAccounts} pass.
3541
     *
3542
     * @param rows        number of {@code npa:PendingAccountState} rows written
3543
     * @param scannedUpTo the new watermark: the highest {@code meta} load number this
3544
     *                    pass looked at, or the incoming watermark when it saw nothing
3545
     */
3546
    record PendingMirrorResult(int rows, long scannedUpTo) {}
27 ✔
3547

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

3551
    /**
3552
     * Reads the approved {@code npa:AccountState} rows of a space-state graph into
3553
     * two lookup sets. Used by {@link #mirrorPendingAccounts} to keep self-asserted
3554
     * introductions away from identities that trust approval has already settled.
3555
     */
3556
    private ApprovedIndex readApprovedIndex(IRI graph) {
3557
        Set<IRI> agents = new HashSet<>();
12 ✔
3558
        Set<String> pubkeys = new HashSet<>();
12 ✔
3559
        String query = String.format("""
63 ✔
3560
                SELECT ?agent ?pubkey WHERE {
3561
                  GRAPH <%1$s> {
3562
                    ?acct a <%2$s> ;
3563
                          <%3$s> ?agent ;
3564
                          <%4$s> ?pubkey .
3565
                  }
3566
                }
3567
                """, graph, NPA_ACCOUNT_STATE, NPA_AGENT, NPA_PUBKEY);
3568
        try (RepositoryConnection conn = TripleStore.get().getRepoConnection(SPACES_REPO);
12 ✔
3569
             TupleQueryResult r = conn.prepareTupleQuery(QueryLanguage.SPARQL, query).evaluate()) {
18 ✔
3570
            while (r.hasNext()) {
9 ✔
3571
                BindingSet b = r.next();
12 ✔
3572
                if (b.getValue("agent") instanceof IRI agent) agents.add(agent);
42 !
3573
                pubkeys.add(b.getValue("pubkey").stringValue());
21 ✔
3574
            }
3 ✔
3575
        } catch (Exception ex) {
3 ✔
3576
            // Must not degrade to an empty index: that would drop both exclusions and
3577
            // let a self-asserted introduction claim an already-approved identity.
3578
            throw new SpaceStateUnavailableException(
24 ✔
3579
                    "failed to read approved account rows from " + graph, ex);
3580
        }
3 ✔
3581
        return new ApprovedIndex(agents, pubkeys);
18 ✔
3582
    }
3583

3584
    /**
3585
     * Mirrors introduced-but-unapproved accounts into {@code graph} as
3586
     * {@code npa:PendingAccountState} rows, so that self-signed observer roles and
3587
     * own-profile view displays from not-yet-approved users become visible
3588
     * (issue #195). Reads introduction nanopubs from {@code npa:graph} of the
3589
     * {@code meta} repo — the registry distributes them for unknown pubkeys too when
3590
     * optional load is on, which is why the data is there at all.
3591
     *
3592
     * <p>Only <em>authoritative</em> introductions count: the declared key must be the
3593
     * key that signed the introduction ({@code SHA256(?pubkey) = ?pkh}), so the other
3594
     * keys of a multi-key introduction are deliberately not mirrored. Self-retraction
3595
     * is honoured through the same publisher-matched {@code npx:invalidates} gate the
3596
     * tiers use (issue #112).
3597
     *
3598
     * <p>Two exclusions keep self-asserted data away from settled identities:
3599
     * an agent that already has an approved {@code AccountState} row is skipped (so a
3600
     * rogue introduction cannot pollute an approved user's page or lists), and so is a
3601
     * pubkey that already has one (so an approved key cannot be re-bound to a second
3602
     * identity).
3603
     *
3604
     * <p>The rows never confer authority: they carry {@link #NPA_PENDING_ACCOUNT_STATE},
3605
     * which no authority join in this class matches. Approval needs no cleanup pass —
3606
     * it flips the trust-state hash, which makes {@link #tick()} run a full build that
3607
     * re-derives everything and simply stops mirroring the pending row.
3608
     *
3609
     * @param graph          the space-state graph to write into
3610
     * @param fromLoadNumber scan only introductions with a {@code meta} load number
3611
     *                       greater than this ({@code -1} scans everything)
3612
     * @return the number of rows written and the new watermark
3613
     */
3614
    PendingMirrorResult mirrorPendingAccounts(IRI graph, long fromLoadNumber) {
3615
        if (!FeatureFlags.pendingAccountsEnabled()) {
6 ✔
3616
            return new PendingMirrorResult(0, fromLoadNumber);
18 ✔
3617
        }
3618
        ApprovedIndex approved = readApprovedIndex(graph);
12 ✔
3619
        // ?pkh, not ?pubkey, is what goes into the row: npa:pubkey on a space-state
3620
        // account row holds the pubkey *hash* (it is joined against the extraction
3621
        // graph's npa:pubkeyHash and against npa:hasValidSignatureForPublicKeyHash),
3622
        // despite the predicate's name. The full key is only used to prove the
3623
        // declaration is authoritative.
3624
        String query = String.format("""
96 ✔
3625
                SELECT DISTINCT ?agent ?pkh ?np ?ln WHERE {
3626
                  GRAPH <%1$s> {
3627
                    ?np <%2$s> ?agent ;
3628
                        <%3$s> ?pubkey ;
3629
                        <%4$s> ?pkh ;
3630
                        <%5$s> ?ln .
3631
                    # Authoritative introduction: the declared key signed it.
3632
                    FILTER (SHA256(?pubkey) = STR(?pkh))
3633
                    # Self-retraction gate (issue #112): only the introduction's own
3634
                    # publisher can invalidate it.
3635
                    FILTER NOT EXISTS {
3636
                      ?inv <%6$s> ?np ;
3637
                           <%4$s> ?pkh .
3638
                    }
3639
                    FILTER (?ln > %7$d)
3640
                  }
3641
                }
3642
                """, NPA.GRAPH, NPA.IS_INTRODUCTION_OF, NPA.DECLARES_PUBKEY,
3643
                NPA.HAS_VALID_SIGNATURE_FOR_PUBLIC_KEY_HASH, NPA.HAS_LOAD_NUMBER,
3644
                NPX.INVALIDATES, fromLoadNumber);
6 ✔
3645

3646
        List<Statement[]> pending = new ArrayList<>();
12 ✔
3647
        long maxLoadNumber = fromLoadNumber;
6 ✔
3648
        try (RepositoryConnection metaConn = TripleStore.get().getRepoConnection(META_REPO);
12 ✔
3649
             TupleQueryResult r = metaConn.prepareTupleQuery(QueryLanguage.SPARQL, query).evaluate()) {
18 ✔
3650
            while (r.hasNext()) {
9 ✔
3651
                BindingSet b = r.next();
12 ✔
3652
                long ln = Long.parseLong(b.getValue("ln").stringValue());
18 ✔
3653
                maxLoadNumber = Math.max(maxLoadNumber, ln);
12 ✔
3654
                if (!(b.getValue("agent") instanceof IRI agent)) continue;
30 !
3655
                if (!(b.getValue("np") instanceof IRI introNp)) continue;
30 !
3656
                Value pubkeyHash = b.getValue("pkh");
12 ✔
3657
                if (approved.agents().contains(agent)) continue;
18 ✔
3658
                if (approved.pubkeys().contains(pubkeyHash.stringValue())) continue;
21 ✔
3659
                IRI rowIri = pendingAccountIri(graph, pubkeyHash.stringValue(), agent);
18 ✔
3660
                pending.add(new Statement[] {
36 ✔
3661
                        vf.createStatement(rowIri, RDF.TYPE, NPA_PENDING_ACCOUNT_STATE, graph),
27 ✔
3662
                        vf.createStatement(rowIri, NPA_AGENT, agent, graph),
27 ✔
3663
                        vf.createStatement(rowIri, NPA_PUBKEY, pubkeyHash, graph),
27 ✔
3664
                        vf.createStatement(rowIri, NPA_TRUST_STATUS, NPA_SEEN, graph),
27 ✔
3665
                        vf.createStatement(rowIri, NPA_VIA_NANOPUB, introNp, graph),
6 ✔
3666
                });
3667
            }
3 ✔
3668
        } catch (Exception ex) {
×
3669
            throw new SpaceStateUnavailableException(
×
3670
                    "failed to read introduction nanopubs from the " + META_REPO + " repo", ex);
3671
        }
3 ✔
3672

3673
        int count = 0;
6 ✔
3674
        try (RepositoryConnection spacesConn = TripleStore.get().getRepoConnection(SPACES_REPO)) {
12 ✔
3675
            // Append-only writes, same rationale as mirrorTrustState: all spaces writers
3676
            // serialise through this class's synchronized methods.
3677
            spacesConn.begin(IsolationLevels.READ_COMMITTED);
9 ✔
3678
            for (Statement[] row : pending) {
30 ✔
3679
                IRI rowIri = (IRI) row[0].getSubject();
18 ✔
3680
                // Re-running a cycle (or a watermark rewind) must not duplicate rows.
3681
                if (spacesConn.hasStatement(rowIri, RDF.TYPE, NPA_PENDING_ACCOUNT_STATE, false, graph)) {
39 ✔
3682
                    continue;
3 ✔
3683
                }
3684
                for (Statement st : row) {
48 ✔
3685
                    spacesConn.add(st);
15 ✔
3686
                }
3687
                count++;
3 ✔
3688
            }
3 ✔
3689
            spacesConn.commit();
6 ✔
3690
        }
3691
        return new PendingMirrorResult(count, maxLoadNumber);
18 ✔
3692
    }
3693

3694
    /**
3695
     * Mints the {@code npaa:} subject of a pending-account row. Keyed on the state
3696
     * graph (which encodes the trust-state hash and the build's load counter), so the
3697
     * IRI is stable within a build and different across builds — the same property
3698
     * {@code TrustStateLoader.accountStateHash} gives approved rows.
3699
     */
3700
    private static IRI pendingAccountIri(IRI graph, String pubkeyHash, IRI agent) {
3701
        String composite = graph.stringValue() + "|" + pubkeyHash + "|" + agent.stringValue() + "|pending";
21 ✔
3702
        return NPAA.forHash(Hashing.sha256().hashString(composite, StandardCharsets.UTF_8).toString());
21 ✔
3703
    }
3704

3705
    /**
3706
     * Runs {@link #mirrorPendingAccounts} and advances the watermark, or logs and
3707
     * carries on when the read fails.
3708
     *
3709
     * <p>Fail-soft is deliberate and is the opposite call from the trust mirror's:
3710
     * pending rows are purely additive and confer no authority, so losing a pass costs
3711
     * visibility for not-yet-approved users until the next cycle, whereas aborting the
3712
     * cycle would stall the whole space state over a display-only feature. The
3713
     * watermark is advanced only on success, so a failed pass is retried in full.
3714
     *
3715
     * @return number of rows written (0 if disabled or on read failure)
3716
     */
3717
    private int mirrorPendingAccountsSafely(IRI graph, boolean fromScratch) {
3718
        long fromLoadNumber = -1;
6 ✔
3719
        try {
3720
            if (!fromScratch) fromLoadNumber = readPendingScannedUpTo(graph);
18 ✔
3721
            PendingMirrorResult result = mirrorPendingAccounts(graph, fromLoadNumber);
15 ✔
3722
            if (result.scannedUpTo() != fromLoadNumber) {
15 !
3723
                writePendingScannedUpTo(graph, result.scannedUpTo());
×
3724
            }
3725
            return result.rows();
9 ✔
3726
        } catch (Exception ex) {
3 ✔
3727
            logger.warn("AuthorityResolver: pending-account mirror failed on {} (watermark stays at {}); "
36 ✔
3728
                    + "not-yet-approved accounts stay invisible until the next cycle: {}",
3729
                    graph, fromLoadNumber, ex.getMessage(), ex);
33 ✔
3730
            return 0;
6 ✔
3731
        }
3732
    }
3733

3734
    // ---------------- Pointer + counter helpers ----------------
3735

3736
    /**
3737
     * Reads the current {@code npa:hasCurrentSpaceState} pointer from the
3738
     * {@code npa:graph} admin graph of the {@code spaces} repo. Returns
3739
     * {@code null} if no pointer exists yet.
3740
     */
3741
    IRI getCurrentSpaceStateGraph() {
3742
        try (RepositoryConnection conn = TripleStore.get().getRepoConnection(SPACES_REPO)) {
12 ✔
3743
            Value v = Utils.getObjectForPattern(conn, NPA.GRAPH, NPA.THIS_REPO,
18 ✔
3744
                    SpacesVocab.HAS_CURRENT_SPACE_STATE);
3745
            return (v instanceof IRI iri) ? iri : null;
36 ✔
3746
        } catch (Exception ex) {
3 ✔
3747
            throw new SpaceStateUnavailableException("failed to read hasCurrentSpaceState pointer", ex);
18 ✔
3748
        }
3749
    }
3750

3751
    long getCurrentLoadCounter() {
3752
        try (RepositoryConnection conn = TripleStore.get().getRepoConnection(SPACES_REPO)) {
12 ✔
3753
            Value v = Utils.getObjectForPattern(conn, NPA.GRAPH, NPA.THIS_REPO,
18 ✔
3754
                    SpacesVocab.CURRENT_LOAD_COUNTER);
3755
            if (v == null) return 0;
18 ✔
3756
            try {
3757
                return Long.parseLong(v.stringValue());
18 ✔
3758
            } catch (NumberFormatException ex) {
3 ✔
3759
                // Was "return 0", which would name the new graph <hash>_0 and make it
3760
                // differ from the real current graph — so the build proceeded and then
3761
                // dropped the good one. Corrupt bookkeeping must stop the build.
3762
                throw new SpaceStateUnavailableException("non-numeric currentLoadCounter: " + v, ex);
24 ✔
3763
            }
3764
        } catch (SpaceStateUnavailableException ex) {
15 !
3765
            throw ex;
6 ✔
3766
        } catch (Exception ex) {
3 ✔
3767
            throw new SpaceStateUnavailableException("failed to read currentLoadCounter", ex);
18 ✔
3768
        }
3769
    }
3770

3771
    /**
3772
     * Atomic pointer flip: a single SPARQL {@code DELETE … INSERT … WHERE}
3773
     * replaces the old pointer with the new one in one statement, so readers
3774
     * never see a zero-pointer window.
3775
     */
3776
    void flipPointer(IRI newGraph) {
3777
        String update = String.format("""
135 ✔
3778
                DELETE { GRAPH <%s> { <%s> <%s> ?old } }
3779
                INSERT { GRAPH <%s> { <%s> <%s> <%s> } }
3780
                WHERE  { OPTIONAL { GRAPH <%s> { <%s> <%s> ?old } } }
3781
                """,
3782
                NPA.GRAPH, NPA.THIS_REPO, SpacesVocab.HAS_CURRENT_SPACE_STATE,
3783
                NPA.GRAPH, NPA.THIS_REPO, SpacesVocab.HAS_CURRENT_SPACE_STATE, newGraph,
3784
                NPA.GRAPH, NPA.THIS_REPO, SpacesVocab.HAS_CURRENT_SPACE_STATE);
3785
        try (RepositoryConnection conn = TripleStore.get().getRepoConnection(SPACES_REPO)) {
12 ✔
3786
            conn.begin(IsolationLevels.SNAPSHOT);
9 ✔
3787
            conn.prepareUpdate(QueryLanguage.SPARQL, update).execute();
15 ✔
3788
            conn.commit();
6 ✔
3789
        }
3790
    }
3 ✔
3791

3792
    void writeProcessedUpTo(IRI graph, long loadCounter) {
3793
        String update = String.format("""
96 ✔
3794
                DELETE { GRAPH <%s> { <%s> <%s> ?old } }
3795
                INSERT { GRAPH <%s> { <%s> <%s> "%d"^^<http://www.w3.org/2001/XMLSchema#long> } }
3796
                WHERE  { OPTIONAL { GRAPH <%s> { <%s> <%s> ?old } } }
3797
                """,
3798
                graph, graph, SpacesVocab.PROCESSED_UP_TO,
3799
                graph, graph, SpacesVocab.PROCESSED_UP_TO, loadCounter,
42 ✔
3800
                graph, graph, SpacesVocab.PROCESSED_UP_TO);
3801
        try (RepositoryConnection conn = TripleStore.get().getRepoConnection(SPACES_REPO)) {
12 ✔
3802
            conn.begin(IsolationLevels.SNAPSHOT);
9 ✔
3803
            conn.prepareUpdate(QueryLanguage.SPARQL, update).execute();
15 ✔
3804
            conn.commit();
6 ✔
3805
        }
3806
    }
3 ✔
3807

3808
    /**
3809
     * Writes the pending-account watermark ({@link SpacesVocab#PENDING_SCANNED_UP_TO})
3810
     * into the given space-state graph. Same replace-in-place shape as
3811
     * {@link #writeProcessedUpTo}.
3812
     */
3813
    void writePendingScannedUpTo(IRI graph, long metaLoadNumber) {
3814
        String update = String.format("""
×
3815
                DELETE { GRAPH <%s> { <%s> <%s> ?old } }
3816
                INSERT { GRAPH <%s> { <%s> <%s> "%d"^^<http://www.w3.org/2001/XMLSchema#long> } }
3817
                WHERE  { OPTIONAL { GRAPH <%s> { <%s> <%s> ?old } } }
3818
                """,
3819
                graph, graph, SpacesVocab.PENDING_SCANNED_UP_TO,
3820
                graph, graph, SpacesVocab.PENDING_SCANNED_UP_TO, metaLoadNumber,
×
3821
                graph, graph, SpacesVocab.PENDING_SCANNED_UP_TO);
3822
        try (RepositoryConnection conn = TripleStore.get().getRepoConnection(SPACES_REPO)) {
×
3823
            conn.begin(IsolationLevels.SNAPSHOT);
×
3824
            conn.prepareUpdate(QueryLanguage.SPARQL, update).execute();
×
3825
            conn.commit();
×
3826
        }
3827
    }
×
3828

3829
    /**
3830
     * Reads the pending-account watermark from the given space-state graph.
3831
     * Returns {@code -1} when absent — which is both the never-scanned case and the
3832
     * upgrade case (a graph built by a version without this step), and correctly makes
3833
     * the next cycle scan every introduction.
3834
     */
3835
    long readPendingScannedUpTo(IRI graph) {
3836
        String query = String.format(
51 ✔
3837
                "SELECT ?n WHERE { GRAPH <%s> { <%s> <%s> ?n } }",
3838
                graph, graph, SpacesVocab.PENDING_SCANNED_UP_TO);
3839
        try (RepositoryConnection conn = TripleStore.get().getRepoConnection(SPACES_REPO);
12 ✔
3840
             TupleQueryResult r = conn.prepareTupleQuery(QueryLanguage.SPARQL, query).evaluate()) {
18 ✔
3841
            if (!r.hasNext()) return -1;
21 !
3842
            return Long.parseLong(r.next().getBinding("n").getValue().stringValue());
×
3843
        } catch (Exception ex) {
24 !
3844
            // Unlike processedUpTo, -1 here is harmless (it re-scans), but a read that
3845
            // failed is still a read failure: let the caller's fail-soft wrapper log it
3846
            // rather than silently rescanning every cycle.
3847
            throw new SpaceStateUnavailableException("failed to read pendingScannedUpTo for " + graph, ex);
×
3848
        }
3849
    }
3850

3851
    /**
3852
     * Rewrites the {@link SpacesVocab#STATE_TRIPLE_COUNT} integrity stamp so it
3853
     * equals the graph's actual triple count (stamp triple included). Runs as a
3854
     * single transaction — delete old stamp, count, insert new stamp — so the
3855
     * stamp is either consistent with the content it was measured against or
3856
     * absent, never half-updated. Called after every mutation of a space-state
3857
     * graph; {@link #tick()} verifies it and rebuilds on mismatch.
3858
     */
3859
    void writeStateTripleCount(IRI graph) {
3860
        try (RepositoryConnection conn = TripleStore.get().getRepoConnection(SPACES_REPO)) {
12 ✔
3861
            conn.begin(IsolationLevels.SNAPSHOT);
9 ✔
3862
            conn.prepareUpdate(QueryLanguage.SPARQL, String.format(
57 ✔
3863
                    "DELETE WHERE { GRAPH <%s> { <%s> <%s> ?old } }",
3864
                    graph, graph, SpacesVocab.STATE_TRIPLE_COUNT)).execute();
3 ✔
3865
            long withoutStamp;
3866
            try (TupleQueryResult r = conn.prepareTupleQuery(QueryLanguage.SPARQL, String.format(
33 ✔
3867
                    "SELECT (COUNT(*) AS ?n) WHERE { GRAPH <%s> { ?s ?p ?o } }", graph)).evaluate()) {
6 ✔
3868
                withoutStamp = Long.parseLong(r.next().getBinding("n").getValue().stringValue());
27 ✔
3869
            }
3870
            // +1 for the stamp triple itself, so a plain count of the graph matches the stamp.
3871
            conn.prepareUpdate(QueryLanguage.SPARQL, String.format(
72 ✔
3872
                    "INSERT DATA { GRAPH <%s> { <%s> <%s> \"%d\"^^<http://www.w3.org/2001/XMLSchema#long> } }",
3873
                    graph, graph, SpacesVocab.STATE_TRIPLE_COUNT, withoutStamp + 1)).execute();
9 ✔
3874
            conn.commit();
6 ✔
3875
        }
3876
    }
3 ✔
3877

3878
    /**
3879
     * Reads the {@link SpacesVocab#STATE_TRIPLE_COUNT} stamp from the given
3880
     * space-state graph. Returns {@code -1} if absent (graph published by a
3881
     * pre-stamp version; it becomes verifiable at its next mutation). Throws on
3882
     * read failure — the same absent-vs-error distinction as
3883
     * {@link #readProcessedUpTo}: a timed-out read must not look like damage.
3884
     */
3885
    long readStateTripleCount(IRI graph) {
3886
        try (RepositoryConnection conn = TripleStore.get().getRepoConnection(SPACES_REPO)) {
12 ✔
3887
            String query = String.format(
51 ✔
3888
                    "SELECT ?n WHERE { GRAPH <%s> { <%s> <%s> ?n } }",
3889
                    graph, graph, SpacesVocab.STATE_TRIPLE_COUNT);
3890
            try (TupleQueryResult r = conn.prepareTupleQuery(QueryLanguage.SPARQL, query).evaluate()) {
18 ✔
3891
                if (!r.hasNext()) return -1;
21 ✔
3892
                return Long.parseLong(r.next().getBinding("n").getValue().stringValue());
33 ✔
3893
            }
12 !
3894
        } catch (Exception ex) {
12 !
3895
            throw new SpaceStateUnavailableException("failed to read stateTripleCount for " + graph, ex);
×
3896
        }
3897
    }
3898

3899
    /**
3900
     * Counts the triples in the given space-state graph. Throws on read failure
3901
     * rather than returning a sentinel, for the same reason as
3902
     * {@link #readStateTripleCount}.
3903
     */
3904
    long countStateGraphTriples(IRI graph) {
3905
        try (RepositoryConnection conn = TripleStore.get().getRepoConnection(SPACES_REPO)) {
12 ✔
3906
            String query = String.format(
27 ✔
3907
                    "SELECT (COUNT(*) AS ?n) WHERE { GRAPH <%s> { ?s ?p ?o } }", graph);
3908
            try (TupleQueryResult r = conn.prepareTupleQuery(QueryLanguage.SPARQL, query).evaluate()) {
18 ✔
3909
                return Long.parseLong(r.next().getBinding("n").getValue().stringValue());
33 ✔
3910
            }
3911
        } catch (Exception ex) {
×
3912
            throw new SpaceStateUnavailableException("failed to count triples of " + graph, ex);
×
3913
        }
3914
    }
3915

3916
    /**
3917
     * Reads {@code processedUpTo} from the given space-state graph.
3918
     * Returns {@code -1} if absent (graph not fully built yet).
3919
     */
3920
    long readProcessedUpTo(IRI graph) {
3921
        try (RepositoryConnection conn = TripleStore.get().getRepoConnection(SPACES_REPO)) {
12 ✔
3922
            String query = String.format(
51 ✔
3923
                    "SELECT ?n WHERE { GRAPH <%s> { <%s> <%s> ?n } }",
3924
                    graph, graph, SpacesVocab.PROCESSED_UP_TO);
3925
            try (TupleQueryResult r = conn.prepareTupleQuery(QueryLanguage.SPARQL, query).evaluate()) {
18 ✔
3926
                if (!r.hasNext()) return -1;
21 ✔
3927
                BindingSet b = r.next();
12 ✔
3928
                return Long.parseLong(b.getBinding("n").getValue().stringValue());
27 ✔
3929
            }
12 !
3930
        } catch (Exception ex) {
15 !
3931
            // Must not collapse to -1: callers read -1 as "this graph was never
3932
            // finished" and rebuild from scratch. A timed-out read returning -1 would
3933
            // make a healthy state look damaged and trigger a destructive rebuild.
3934
            throw new SpaceStateUnavailableException("failed to read processedUpTo for " + graph, ex);
24 ✔
3935
        }
3936
    }
3937

3938
    /**
3939
     * Reads the {@code npa:needsFullRebuild} flag (boolean literal) from
3940
     * {@code npa:graph} in the {@code spaces} repo. Defaults to {@code false}
3941
     * when the triple is absent.
3942
     */
3943
    boolean readNeedsFullRebuild() {
3944
        try (RepositoryConnection conn = TripleStore.get().getRepoConnection(SPACES_REPO)) {
12 ✔
3945
            Value v = Utils.getObjectForPattern(conn, NPA.GRAPH, NPA.THIS_REPO,
18 ✔
3946
                    SpacesVocab.NEEDS_FULL_REBUILD);
3947
            return v != null && Boolean.parseBoolean(v.stringValue());
36 ✔
3948
        } catch (Exception ex) {
3 ✔
3949
            logger.warn("AuthorityResolver: failed to read needsFullRebuild: {}", ex.toString());
15 ✔
3950
            return false;
6 ✔
3951
        }
3952
    }
3953

3954
    void setNeedsFullRebuild() {
3955
        writeNeedsFullRebuild(true);
9 ✔
3956
    }
3 ✔
3957

3958
    void clearNeedsFullRebuild() {
3959
        writeNeedsFullRebuild(false);
9 ✔
3960
    }
3 ✔
3961

3962
    private void writeNeedsFullRebuild(boolean value) {
3963
        String update = String.format("""
96 ✔
3964
                DELETE { GRAPH <%s> { <%s> <%s> ?old } }
3965
                INSERT { GRAPH <%s> { <%s> <%s> "%s"^^<http://www.w3.org/2001/XMLSchema#boolean> } }
3966
                WHERE  { OPTIONAL { GRAPH <%s> { <%s> <%s> ?old } } }
3967
                """,
3968
                NPA.GRAPH, NPA.THIS_REPO, SpacesVocab.NEEDS_FULL_REBUILD,
3969
                NPA.GRAPH, NPA.THIS_REPO, SpacesVocab.NEEDS_FULL_REBUILD, value,
42 ✔
3970
                NPA.GRAPH, NPA.THIS_REPO, SpacesVocab.NEEDS_FULL_REBUILD);
3971
        try (RepositoryConnection conn = TripleStore.get().getRepoConnection(SPACES_REPO)) {
12 ✔
3972
            conn.begin(IsolationLevels.SNAPSHOT);
9 ✔
3973
            conn.prepareUpdate(QueryLanguage.SPARQL, update).execute();
15 ✔
3974
            conn.commit();
6 ✔
3975
        }
3976
    }
3 ✔
3977

3978
    void dropGraph(IRI graph) {
3979
        try (RepositoryConnection conn = TripleStore.get().getRepoConnection(SPACES_REPO)) {
12 ✔
3980
            conn.begin(IsolationLevels.SNAPSHOT);
9 ✔
3981
            conn.clear(graph);
24 ✔
3982
            conn.commit();
6 ✔
3983
            logger.info("AuthorityResolver: dropped old space-state graph {}", graph);
12 ✔
3984
        }
3985
    }
3 ✔
3986

3987
    // ---------------- Trust-repo pointer lookup (used by TrustStateRegistry's bootstrap) ----------------
3988

3989
    /**
3990
     * Queries the {@code trust} repo directly for the current trust-state hash.
3991
     * Prefer {@link TrustStateRegistry#getCurrentHash()} in normal operation —
3992
     * this helper exists for tests and diagnostics.
3993
     *
3994
     * @return the current trust-state hash, or empty if none is set
3995
     */
3996
    Optional<String> readTrustRepoCurrentHash() {
3997
        try (RepositoryConnection conn = TripleStore.get().getRepoConnection(TRUST_REPO)) {
12 ✔
3998
            Value v = Utils.getObjectForPattern(conn, NPA.GRAPH, NPA.THIS_REPO,
18 ✔
3999
                    NPA_HAS_CURRENT_TRUST_STATE);
4000
            if (!(v instanceof IRI iri)) return Optional.empty();
33 ✔
4001
            String s = iri.stringValue();
9 ✔
4002
            if (!s.startsWith(NPAT.NAMESPACE)) return Optional.empty();
24 ✔
4003
            return Optional.of(s.substring(NPAT.NAMESPACE.length()));
24 ✔
4004
        } catch (Exception ex) {
27 !
4005
            logger.warn("AuthorityResolver: failed to read trust-repo current pointer: {}", ex.toString());
15 ✔
4006
            return Optional.empty();
6 ✔
4007
        }
4008
    }
4009

4010
    private static String abbrev(String hash) {
4011
        return hash.length() > 12 ? hash.substring(0, 12) + "…" : hash;
33 !
4012
    }
4013

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