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

knowledgepixels / nanopub-query / 28156016816

25 Jun 2026 08:05AM UTC coverage: 61.236% (-0.1%) from 61.331%
28156016816

Pull #132

github

web-flow
Merge 8842cd852 into 43f98937a
Pull Request #132: feat(spaces): materialize uniform ref-valued hasGoverningSpaceRef edge (#130)

541 of 976 branches covered (55.43%)

Branch coverage included in aggregate %.

1579 of 2486 relevant lines covered (63.52%)

9.65 hits per line

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

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

3
import java.util.ArrayList;
4
import java.util.List;
5
import java.util.Optional;
6
import java.util.Set;
7

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

26
import com.knowledgepixels.query.vocabulary.GEN;
27
import com.knowledgepixels.query.vocabulary.NPAT;
28
import com.knowledgepixels.query.vocabulary.SpacesVocab;
29

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

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

59
    private static final ValueFactory vf = SimpleValueFactory.getInstance();
6✔
60

61
    private static final String SPACES_REPO = "spaces";
62
    private static final String TRUST_REPO = "trust";
63

64
    /** NPA constants pulled in locally (trust-side). */
65
    private static final IRI NPA_HAS_CURRENT_TRUST_STATE =
9✔
66
            vf.createIRI(NPA.NAMESPACE, "hasCurrentTrustState");
6✔
67
    private static final IRI NPA_ACCOUNT_STATE = vf.createIRI(NPA.NAMESPACE, "AccountState");
15✔
68
    private static final IRI NPA_AGENT = vf.createIRI(NPA.NAMESPACE, "agent");
15✔
69
    private static final IRI NPA_PUBKEY = vf.createIRI(NPA.NAMESPACE, "pubkey");
15✔
70
    private static final IRI NPA_TRUST_STATUS = vf.createIRI(NPA.NAMESPACE, "trustStatus");
15✔
71
    private static final IRI NPA_LOADED = vf.createIRI(NPA.NAMESPACE, "loaded");
15✔
72
    private static final IRI NPA_TO_LOAD = vf.createIRI(NPA.NAMESPACE, "toLoad");
15✔
73

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

84
    private static AuthorityResolver instance;
85

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

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

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

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

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

118
    // ---------------- Public entry points ----------------
119

120
    /**
121
     * Poll entry point. Behaviour:
122
     * <ul>
123
     *   <li>If no current space-state graph or the trust state has flipped → full build.</li>
124
     *   <li>Otherwise → {@link #runIncrementalCycle incremental cycle} on the load-number
125
     *       delta {@code (processedUpTo, currentLoadCounter]}. No-op if {@code
126
     *       processedUpTo == currentLoadCounter}.</li>
127
     * </ul>
128
     * Safe to call repeatedly on a schedule. Gated by {@link FeatureFlags#spacesEnabled()}.
129
     */
130
    public void tick() {
131
        if (!FeatureFlags.spacesEnabled()) return;
6!
132
        String trustStateHash = TrustStateRegistry.get().getCurrentHash().orElse(null);
18✔
133
        if (trustStateHash == null) {
6!
134
            logger.debug("AuthorityResolver.tick: no current trust state yet — skipping");
9✔
135
            return;
3✔
136
        }
137
        IRI currentGraph = getCurrentSpaceStateGraph();
×
138
        String currentGraphName = (currentGraph == null) ? null
×
139
                : currentGraph.stringValue().substring(SpacesVocab.NPASS_NAMESPACE.length());
×
140
        if (currentGraphName == null || !currentGraphName.startsWith(trustStateHash + "_")) {
×
141
            logger.info("AuthorityResolver.tick: trust-state flip detected (now {}); running full build",
×
142
                    abbrev(trustStateHash));
×
143
            runFullBuild(trustStateHash);
×
144
            return;
×
145
        }
146
        runIncrementalCycle(currentGraph);
×
147
    }
×
148

149
    /**
150
     * Periodic worker. If {@code npa:needsFullRebuild} was raised by an
151
     * incremental cycle's structural DELETE, runs a from-scratch rebuild into
152
     * a fresh space-state graph (using the current trust-state hash and load
153
     * counter) and clears the flag. No-op when the flag is not set. Safe to
154
     * call concurrently with {@link #tick()} when both are scheduled on the
155
     * same single-threaded executor.
156
     */
157
    public void periodicRebuildTick() {
158
        if (!FeatureFlags.spacesEnabled()) return;
×
159
        if (!readNeedsFullRebuild()) return;
×
160
        String trustStateHash = TrustStateRegistry.get().getCurrentHash().orElse(null);
×
161
        if (trustStateHash == null) {
×
162
            logger.debug("AuthorityResolver.periodicRebuildTick: no current trust state — deferring");
×
163
            return;
×
164
        }
165
        logger.info("AuthorityResolver.periodicRebuildTick: needsFullRebuild flag set; rebuilding");
×
166
        runFullBuild(trustStateHash);
×
167
        clearNeedsFullRebuild();
×
168
    }
×
169

170
    /**
171
     * Startup cleanup: drop any {@code npass:*} graph that the
172
     * {@code npa:hasCurrentSpaceState} pointer isn't pointing at. Orphans come
173
     * from crashes mid-build. Safe to call at any time; idempotent.
174
     */
175
    public void cleanOrphans() {
176
        if (!FeatureFlags.spacesEnabled()) return;
×
177
        IRI current = getCurrentSpaceStateGraph();
×
178
        try (RepositoryConnection conn = TripleStore.get().getRepoConnection(SPACES_REPO)) {
×
179
            int dropped = 0;
×
180
            try (RepositoryResult<org.eclipse.rdf4j.model.Resource> ctxs = conn.getContextIDs()) {
×
181
                List<IRI> toDrop = new ArrayList<>();
×
182
                while (ctxs.hasNext()) {
×
183
                    org.eclipse.rdf4j.model.Resource ctx = ctxs.next();
×
184
                    if (!(ctx instanceof IRI iri)) continue;
×
185
                    if (!iri.stringValue().startsWith(SpacesVocab.NPASS_NAMESPACE)) continue;
×
186
                    if (iri.equals(current)) continue;
×
187
                    toDrop.add(iri);
×
188
                }
×
189
                for (IRI iri : toDrop) {
×
190
                    conn.begin(IsolationLevels.SERIALIZABLE);
×
191
                    conn.clear(iri);
×
192
                    conn.commit();
×
193
                    dropped++;
×
194
                    logger.info("AuthorityResolver.cleanOrphans: dropped orphan graph {}", iri);
×
195
                }
×
196
            }
197
            if (dropped == 0) {
×
198
                logger.debug("AuthorityResolver.cleanOrphans: no orphan space-state graphs");
×
199
            }
200
        } catch (Exception ex) {
×
201
            logger.info("AuthorityResolver.cleanOrphans: failed: {}", ex.toString());
×
202
        }
×
203
    }
×
204

205
    // ---------------- Full build ----------------
206

207
    /**
208
     * Mutex-protected full build of the space-state graph for the given trust
209
     * state. Captures {@code M = currentLoadCounter}, mirrors trust-approved
210
     * rows, (PR 2b: runs per-tier UPDATE loops from scratch), stamps
211
     * {@code processedUpTo = M}, flips the pointer, drops the previous graph.
212
     */
213
    synchronized void runFullBuild(String trustStateHash) {
214
        long startNanos = System.nanoTime();
×
215
        long loadCounter = getCurrentLoadCounter();
×
216
        IRI newGraph = SpacesVocab.forSpaceState(trustStateHash, loadCounter);
×
217
        IRI oldGraph = getCurrentSpaceStateGraph();
×
218
        if (newGraph.equals(oldGraph)) {
×
219
            logger.debug("AuthorityResolver.runFullBuild: already current at {}", newGraph);
×
220
            return;
×
221
        }
222

223
        // 1. Mirror trust-approved rows into the new graph.
224
        int mirrored = mirrorTrustState(trustStateHash, newGraph);
×
225

226
        // 2. Per-tier UPDATE loops (from scratch: lastProcessed = -1 so the
227
        //    delta filter FILTER(?ln > ?lastProcessed) includes everything).
228
        TierInsertedTriples counts = runAllTierLoops(newGraph, -1);
×
229

230
        // 3. Stamp processedUpTo inside the new graph.
231
        writeProcessedUpTo(newGraph, loadCounter);
×
232

233
        // 4. Flip the current-space-state pointer.
234
        flipPointer(newGraph);
×
235

236
        // 5. Drop the old graph if one existed.
237
        if (oldGraph != null) {
×
238
            dropGraph(oldGraph);
×
239
        }
240

241
        TierSubjectTotals totals = computeTierSubjectTotals(newGraph);
×
242
        long durationMs = (System.nanoTime() - startNanos) / 1_000_000L;
×
243
        lastSubjectTotals = totals;
×
244
        lastInsertedTriplesTotal = (long) counts.admin + counts.alias + counts.presetAttachment
×
245
                + counts.presetAssignmentRef
246
                + counts.attachment + counts.maintainer + counts.member + counts.observer
247
                + counts.subSpace + counts.subSpacePrefix + counts.maintainedResource
248
                + counts.governingSpaceRef;
249
        lastFullBuildDurationMs = durationMs;
×
250
        lastProcessedUpToLag = 0L;
×
251
        logger.info("AuthorityResolver: full build complete — graph={} mirrored={} rows loadCounter={} "
×
252
                        + "subjects: adminRIs={} attachmentRAs={} nonAdminRIs={} "
253
                        + "(inserted-triples: admin={} alias={} preset-attachment={} preset-assignment-ref={} attachment={} maintainer={} member={} observer={} "
254
                        + "subspace={} subspace-prefix={} maintained-resource={} governing-space-ref={}) durationMs={}",
255
                newGraph, mirrored, loadCounter,
×
256
                totals.adminRIs(), totals.attachmentRAs(), totals.nonAdminRIs(),
×
257
                counts.admin, counts.alias, counts.presetAttachment, counts.presetAssignmentRef, counts.attachment, counts.maintainer, counts.member, counts.observer,
×
258
                counts.subSpace, counts.subSpacePrefix, counts.maintainedResource, counts.governingSpaceRef,
×
259
                durationMs);
×
260
    }
×
261

262
    // ---------------- Incremental cycle ----------------
263

264
    /**
265
     * Single delta cycle on the current space-state graph. Bounded by
266
     * {@code (processedUpTo, currentLoadCounter]}; no-op if the range is empty.
267
     *
268
     * <p>Order:
269
     * <ol>
270
     *   <li>Apply invalidation DELETEs (admin RI, RoleAssignment, non-admin RI)
271
     *       and the RoleDeclaration ASK. Any DELETE on a structural kind sets
272
     *       {@code npa:needsFullRebuild} to bound the staleness from sticky
273
     *       downstream entries; the periodic worker turns that into a from-scratch
274
     *       rebuild on its next pass.</li>
275
     *   <li>Run per-tier INSERTs in the same order as the full build.</li>
276
     *   <li>Late-arrival sweep: if any structural row was added, re-run downstream
277
     *       tier INSERTs with {@code lastProcessed = -1} to catch candidates whose
278
     *       enabling event landed in this same cycle. Dedup filters protect
279
     *       against double-insert.</li>
280
     *   <li>Bump {@code processedUpTo} to {@code currentLoadCounter}.</li>
281
     * </ol>
282
     */
283
    synchronized void runIncrementalCycle(IRI graph) {
284
        long startNanos = System.nanoTime();
×
285
        long currentLoadCounter = getCurrentLoadCounter();
×
286
        long lastProcessed = readProcessedUpTo(graph);
×
287
        if (lastProcessed < 0) {
×
288
            logger.warn("AuthorityResolver.runIncrementalCycle: missing processedUpTo on {}; skipping",
×
289
                    graph);
290
            return;
×
291
        }
292
        lastProcessedUpToLag = currentLoadCounter - lastProcessed;
×
293
        if (currentLoadCounter <= lastProcessed) {
×
294
            logger.debug("AuthorityResolver.runIncrementalCycle: caught up at load {} on {}",
×
295
                    currentLoadCounter, graph);
×
296
            return;
×
297
        }
298

299
        boolean structuralInvalidation = applyInvalidations(graph, lastProcessed);
×
300
        TierInsertedTriples counts = runAllTierLoops(graph, lastProcessed);
×
301
        boolean structuralAdds = (counts.admin > 0)
×
302
                || (counts.alias > 0)
303
                || (counts.presetAttachment > 0)
304
                || (counts.attachment > 0)
305
                || (counts.subSpace > 0)
306
                || newRoleDeclarationsArrived(lastProcessed)
×
307
                || newPresetAssignmentsArrived(lastProcessed);
×
308
        if (structuralAdds) {
×
309
            // Late-arrival sweep: leaf tiers (attachment/maintainer/member/observer)
310
            // can promote candidates whose enabling event arrived in this same cycle.
311
            // Sub-space admit is also re-run here for Mode-B late-arrival (a new
312
            // partner declaration can validate an older primary that the regular
313
            // pass's load-number filter excluded). The URL-prefix fallback also
314
            // re-runs so newly-orphaned children pick up derived edges. Skip the
315
            // admin tier — its only enabling event is the admin grant itself,
316
            // already handled by the regular pass.
317
            TierInsertedTriples lateCounts = runDownstreamWithoutLoadFilter(graph);
×
318
            counts.alias              += lateCounts.alias;
×
319
            counts.presetAttachment   += lateCounts.presetAttachment;
×
320
            counts.presetAssignmentRef += lateCounts.presetAssignmentRef;
×
321
            counts.attachment         += lateCounts.attachment;
×
322
            counts.maintainer         += lateCounts.maintainer;
×
323
            counts.member             += lateCounts.member;
×
324
            counts.observer           += lateCounts.observer;
×
325
            counts.subSpace           += lateCounts.subSpace;
×
326
            counts.subSpacePrefix     += lateCounts.subSpacePrefix;
×
327
            counts.governingSpaceRef  += lateCounts.governingSpaceRef;
×
328
            counts.maintainedResource += lateCounts.maintainedResource;
×
329
        }
330

331
        writeProcessedUpTo(graph, currentLoadCounter);
×
332

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

354
    /**
355
     * Runs the four invalidation-DELETE / ASK steps. Sets {@code npa:needsFullRebuild}
356
     * when admin-RI, RoleAssignment, or RoleDeclaration invalidations matched (the
357
     * three structural kinds). Leaf-tier RI deletes don't set the flag.
358
     *
359
     * @return true iff at least one structural kind was invalidated
360
     */
361
    boolean applyInvalidations(IRI graph, long lastProcessed) {
362
        boolean structural = false;
×
363
        if (wouldInvalidate(graph, lastProcessed, /*adminPinned=*/ true,
×
364
                            adminInvalidationCheckWhere(graph, lastProcessed))) {
×
365
            executeUpdate(adminInvalidationDelete(graph, lastProcessed));
×
366
            structural = true;
×
367
        }
368
        if (wouldInvalidate(graph, lastProcessed, /*adminPinned=*/ false,
×
369
                            roleAssignmentInvalidationCheckWhere(graph, lastProcessed))) {
×
370
            executeUpdate(roleAssignmentInvalidationDelete(graph, lastProcessed));
×
371
            structural = true;
×
372
        }
373
        // Role-declaration invalidation is deliberately NOT acted on (see
374
        // nonAdminTierUpdate): a role assignment is governed by the admin-validated
375
        // attachment, not by the declaration author's later supersession/retraction, so
376
        // an invalidated RD neither deletes rows nor triggers a rebuild.
377
        // Sub-space declarations are structural — invalidating one (Mode A) or one
378
        // of two co-declarations (Mode B) changes the validated parent/child
379
        // topology. The DELETE removes the per-declaration row; the convenience-edge
380
        // cleanup then drops the now-unbacked direct triples (issue #125 finding #5)
381
        // instead of leaving them sticky until the periodic rebuild. The structural
382
        // flag still fires so downstream rows derived through a removed edge stay
383
        // rebuild-bounded.
384
        if (wouldInvalidate(graph, lastProcessed, /*adminPinned=*/ false,
×
385
                            subSpaceInvalidationCheckWhere(graph, lastProcessed))) {
×
386
            executeUpdate(subSpaceInvalidationDelete(graph, lastProcessed));
×
387
            executeUpdate(subSpaceConvenienceEdgeCleanup(graph, lastProcessed));
×
388
            structural = true;
×
389
        }
390
        // Space-alias declarations are structural — invalidating one removes an
391
        // owl:sameAs edge that feeds the admin-authority closure (issue #113). The
392
        // DELETE removes the per-declaration row; the convenience-edge cleanup then
393
        // drops the now-unbacked npa:sameAsSpace edge (issue #125 finding #5 — the
394
        // load-bearing case), so admin authority can no longer outlive a retraction
395
        // until the next periodic rebuild.
396
        if (wouldInvalidate(graph, lastProcessed, /*adminPinned=*/ false,
×
397
                            aliasInvalidationCheckWhere(graph, lastProcessed))) {
×
398
            executeUpdate(aliasInvalidationDelete(graph, lastProcessed));
×
399
            executeUpdate(aliasConvenienceEdgeCleanup(graph, lastProcessed));
×
400
            structural = true;
×
401
        }
402
        // Preset-derived RoleAssignment removal (issue #302). NOT npx:invalidates: a newer
403
        // admin-authored same-(preset,resource) assignment supersedes by dct:created (a
404
        // gen:DeactivatedPresetAssignment, or any newer assignment that is no longer active).
405
        // Structural — sticky downstream non-admin RIs derived through a removed attachment
406
        // are bounded by the periodic full rebuild. The DELETE is scoped by
407
        // npa:derivedFromPreset so directly-published gen:hasRole attachments are never
408
        // touched; the §4.3 re-INSERT re-materializes only currently-active pairs in the same
409
        // cycle. See doc/design-preset-role-materialization.md §4.4.
410
        if (wouldInvalidate(graph, lastProcessed, /*adminPinned=*/ false,
×
411
                            presetDeactivationCheckWhere(graph, lastProcessed))) {
×
412
            executeUpdate(presetDeactivationDelete(graph, lastProcessed));
×
413
            structural = true;
×
414
        }
415
        // Leaf-tier RI deletes — no flag.
416
        executeUpdate(leafTierInvalidationDelete(graph, lastProcessed));
×
417
        // Ref-scoped preset-assignment listing stamps whose assignment nanopub was
418
        // hard-retracted (issue #122) — no flag (display leaf, nothing downstream).
419
        executeUpdate(presetAssignmentRefInvalidationDelete(graph, lastProcessed));
×
420
        // Maintained-resource declaration deletes — no flag (leaf relation, no
421
        // downstream caches to bound). The per-declaration delete removes the row; the
422
        // convenience-edge cleanup drops the now-unbacked isMaintainedBy edges (issue
423
        // #125 finding #5). Guarded so the orphan-sweep only scans when something was
424
        // actually invalidated (the delete itself was already a no-op otherwise).
425
        if (wouldInvalidate(graph, lastProcessed, /*adminPinned=*/ false,
×
426
                            maintainedResourceInvalidationCheckWhere(graph, lastProcessed))) {
×
427
            executeUpdate(maintainedResourceInvalidationDelete(graph, lastProcessed));
×
428
            executeUpdate(maintainedResourceConvenienceEdgeCleanup(graph, lastProcessed));
×
429
        }
430
        if (structural) setNeedsFullRebuild();
×
431
        return structural;
×
432
    }
433

434
    /**
435
     * Runs the four leaf tiers (attachment/maintainer/member/observer) with
436
     * {@code lastProcessed = -1} so the load-number filter on the candidate
437
     * side admits everything. Dedup filters in the tier templates prevent
438
     * double-insert. Used by the late-arrival sweep.
439
     */
440
    TierInsertedTriples runDownstreamWithoutLoadFilter(IRI graph) {
441
        TierInsertedTriples c = new TierInsertedTriples();
×
442
        // Alias late-arrival: catches alias declarations whose canonical admin grant
443
        // became valid only in this same cycle (the load-number filter on the
444
        // declaration's nanopub would otherwise exclude it). Runs first so the
445
        // attachment / role tiers below see this cycle's fresh npa:sameAsSpace edges.
446
        c.alias = runTierLabeled("alias(late)", graph, aliasAdmitUpdate(graph, -1));
×
447
        // Sub-space late-arrival: catches Mode-B candidates whose primary
448
        // declaration is older than lastProcessed but whose partner just landed.
449
        c.subSpace = runTierLabeled("subspace(late)", graph,
×
450
                subSpaceAdmitUpdate(graph, -1));
×
451
        // Maintained-resource late-arrival: catches declarations that landed
452
        // before the publisher's admin grant became valid in this state.
453
        c.maintainedResource = runTierLabeled("maintained-resource(late)", graph,
×
454
                maintainedResourceAdmitUpdate(graph, -1));
×
455
        // URL-prefix fallback: re-run after the late-arrival sub-space admit so
456
        // any newly-validated children get their fallback edges suppressed (for
457
        // future inserts) and any newly-orphaned children pick up fallback edges.
458
        c.subSpacePrefix = runTierLabeled("subspace-prefix(late)", graph,
×
459
                subSpacePrefixFallbackUpdate(graph));
×
460
        // Reflexive governing-space-ref late sweep (issue #130): catches refs whose
461
        // SpaceRef aggregate became visible only this cycle. Self-healing dedup.
462
        c.governingSpaceRef = runTierLabeled("governing-space-ref(late)", graph,
×
463
                governingSpaceRefReflexiveUpdate(graph));
×
464
        // Preset-attachment late-arrival: catches assignments whose preset declaration or
465
        // admin grant only became valid in this same cycle. Runs before attachment(late)
466
        // so the non-admin late tiers below see this cycle's fresh preset-derived RAs.
467
        c.presetAttachment = runTierLabeled("preset-attachment(late)", graph,
×
468
                presetAttachmentValidationUpdate(graph, -1));
×
469
        // Ref-scoped preset-assignment late stamp: catches assignments whose authorizing
470
        // admin grant only became valid this cycle (the load filter would skip the older
471
        // assignment nanopub). Mirrors the preset-attachment late sweep above.
472
        c.presetAssignmentRef = runTierLabeled("preset-assignment-ref(late)", graph,
×
473
                presetAssignmentRefStampUpdate(graph, -1));
×
474
        c.attachment = runTierLabeled("attachment(late)", graph,
×
475
                attachmentValidationUpdate(graph, -1));
×
476
        c.maintainer = runTierLabeled("maintainer(late)", graph,
×
477
                nonAdminTierUpdate(graph, -1, GEN.MAINTAINER_ROLE, PUBLISHER_IS_ADMIN));
×
478
        c.member = runTierLabeled("member(admin-pub,late)", graph,
×
479
                nonAdminTierUpdate(graph, -1, GEN.MEMBER_ROLE, PUBLISHER_IS_ADMIN));
×
480
        c.member += runTierLabeled("member(maint-pub,late)", graph,
×
481
                nonAdminTierUpdate(graph, -1,
×
482
                        GEN.MEMBER_ROLE, publisherIsTieredRole(GEN.MAINTAINER_ROLE)));
×
483
        c.observer = runTierLabeled("observer(admin-pub,late)", graph,
×
484
                nonAdminTierUpdate(graph, -1, GEN.OBSERVER_ROLE, PUBLISHER_IS_ADMIN));
×
485
        c.observer += runTierLabeled("observer(maint-pub,late)", graph,
×
486
                nonAdminTierUpdate(graph, -1,
×
487
                        GEN.OBSERVER_ROLE, publisherIsTieredRole(GEN.MAINTAINER_ROLE)));
×
488
        c.observer += runTierLabeled("observer(member-pub,late)", graph,
×
489
                nonAdminTierUpdate(graph, -1,
×
490
                        GEN.OBSERVER_ROLE, publisherIsTieredRole(GEN.MEMBER_ROLE)));
×
491
        c.observer += runTierLabeled("observer(self,late)", graph,
×
492
                nonAdminTierUpdate(graph, -1, GEN.OBSERVER_ROLE, PUBLISHER_IS_SELF));
×
493
        return c;
×
494
    }
495

496
    /**
497
     * Cheap ASK: did any new {@code npa:RoleDeclaration} extraction land in the
498
     * load-number delta {@code (lastProcessed, ∞)}? Used by the late-arrival
499
     * trigger so an RD that arrives in the same cycle as a matching candidate
500
     * still gets validated.
501
     */
502
    boolean newRoleDeclarationsArrived(long lastProcessed) {
503
        String ask = String.format("""
×
504
                PREFIX npa: <%1$s>
505
                ASK {
506
                  GRAPH <%2$s> {
507
                    ?rd a npa:RoleDeclaration ;
508
                        npa:viaNanopub ?np .
509
                  }
510
                  GRAPH <%3$s> {
511
                    ?np npa:hasLoadNumber ?ln .
512
                    FILTER (?ln > %4$d)
513
                  }
514
                }
515
                """, NPA.NAMESPACE, SpacesVocab.SPACES_GRAPH, NPA.GRAPH, lastProcessed);
×
516
        return runAsk(ask);
×
517
    }
518

519
    /**
520
     * Cheap ASK: did any new {@code npa:PresetAssignment} or {@code npa:PresetDeclaration}
521
     * extraction land in the load-number delta {@code (lastProcessed, ∞)}? Drives the
522
     * late-arrival re-run so a preset assignment that arrives in the same cycle as its
523
     * declaration (or admin grant) still materializes, and so an arriving newer assignment
524
     * triggers the deactivation/latest-wins re-evaluation.
525
     */
526
    boolean newPresetAssignmentsArrived(long lastProcessed) {
527
        String ask = String.format("""
×
528
                PREFIX npa: <%1$s>
529
                ASK {
530
                  GRAPH <%2$s> {
531
                    ?x a ?t ;
532
                       npa:viaNanopub ?np .
533
                    FILTER (?t = npa:PresetAssignment || ?t = npa:PresetDeclaration)
534
                  }
535
                  GRAPH <%3$s> {
536
                    ?np npa:hasLoadNumber ?ln .
537
                    FILTER (?ln > %4$d)
538
                  }
539
                }
540
                """, NPA.NAMESPACE, SpacesVocab.SPACES_GRAPH, NPA.GRAPH, lastProcessed);
×
541
        return runAsk(ask);
×
542
    }
543

544
    // ---------------- Tier UPDATE loops ----------------
545

546
    /**
547
     * Per-tier inserted-triple tallies for one build or cycle. Counts the sum
548
     * of {@code (graphSize_after - graphSize_before)} across all iterations of
549
     * each tier's fixed-point INSERT loop — i.e. inserted *triples*, not
550
     * distinct subjects (a single RoleInstantiation insert writes 4–5 triples).
551
     *
552
     * <p>Used internally by the {@link #runIncrementalCycle structuralAdds}
553
     * boolean check (we only care whether any tier inserted at all).
554
     * Not what the log lines report: see {@link TierSubjectTotals} +
555
     * {@link #computeTierSubjectTotals} for the distinct-subject totals
556
     * surfaced to operators.
557
     */
558
    static final class TierInsertedTriples {
×
559
        int admin;
560
        int alias;
561
        int presetAttachment;
562
        int presetAssignmentRef;
563
        int attachment;
564
        int maintainer;
565
        int member;
566
        int observer;
567
        int subSpace;
568
        int subSpacePrefix;
569
        int maintainedResource;
570
        int governingSpaceRef;
571
    }
572

573
    /**
574
     * Snapshot of distinct-subject totals in a space-state graph at a moment
575
     * in time. Independent of which tier-loop added each subject.
576
     */
577
    record TierSubjectTotals(long adminRIs, long attachmentRAs, long nonAdminRIs) {}
36✔
578

579
    /**
580
     * Runs the five tier loops in order: admin → {@code gen:hasRole} attachment
581
     * validation → maintainer → member → observer. Each loop iterates a SPARQL
582
     * INSERT to fixed point (no new triples added). Returns per-tier counts.
583
     *
584
     * @param graph         target space-state graph
585
     * @param lastProcessed load-number horizon; use {@code -1} for full build
586
     */
587
    TierInsertedTriples runAllTierLoops(IRI graph, long lastProcessed) {
588
        TierInsertedTriples c = new TierInsertedTriples();
×
589
        c.admin = runTierLabeled("admin", graph, adminTierUpdate(graph, lastProcessed));
×
590
        // Alias admit runs after the admin closure has settled (both the authority
591
        // gate and the anti-hijack check read the admin set) and before attachment /
592
        // role tiers (their alias-aware admin lookups consume the npa:sameAsSpace edge
593
        // this pass emits). See issue #113.
594
        c.alias = runTierLabeled("alias", graph, aliasAdmitUpdate(graph, lastProcessed));
×
595
        // Sub-space admit runs after admin closure has settled (Mode A + Mode B both
596
        // need the admin set). Independent of role tiers — order between subspace
597
        // and attachment / maintainer / member / observer doesn't matter.
598
        c.subSpace = runTierLabeled("subspace", graph, subSpaceAdmitUpdate(graph, lastProcessed));
×
599
        // Maintained-resource admit also depends only on the admin closure. Single
600
        // Mode A: publisher must be admin of the maintaining space. No co-declaration
601
        // partner, no URL-prefix fallback.
602
        c.maintainedResource = runTierLabeled("maintained-resource", graph,
×
603
                maintainedResourceAdmitUpdate(graph, lastProcessed));
×
604
        // URL-prefix sub-space fallback runs after the explicit-declaration admit
605
        // pass commits so the per-child suppression check sees this cycle's fresh
606
        // validations. No load filter — depends on which Spaces exist, not on
607
        // delta-arrivals; the dedup FILTER NOT EXISTS prevents re-insertion.
608
        c.subSpacePrefix = runTierLabeled("subspace-prefix", graph,
×
609
                subSpacePrefixFallbackUpdate(graph));
×
610
        // Reflexive governing-space-ref edges (issue #130). Self-healing, no load filter;
611
        // runs after the maintained-resource admit so a maintained resource that is itself
612
        // a space already has its maintained governing edge by now (the two are independent
613
        // anyway — different subjects/objects). Order vs. other tiers doesn't matter.
614
        c.governingSpaceRef = runTierLabeled("governing-space-ref", graph,
×
615
                governingSpaceRefReflexiveUpdate(graph));
×
616
        // Preset-attachment runs immediately before the regular attachment tier so the
617
        // gen:RoleAssignment rows it materializes (from active, admin-authored preset
618
        // assignments) are picked up by the downstream non-admin tiers in the same pass,
619
        // exactly like directly-published attachments. See
620
        // doc/design-preset-role-materialization.md.
621
        c.presetAttachment = runTierLabeled("preset-attachment", graph,
×
622
                presetAttachmentValidationUpdate(graph, lastProcessed));
×
623
        // Ref-scoped preset-assignment listing stamp (issue #122). Display-only leaf —
624
        // independent of the role tiers and of structuralAdds; order doesn't matter.
625
        c.presetAssignmentRef = runTierLabeled("preset-assignment-ref", graph,
×
626
                presetAssignmentRefStampUpdate(graph, lastProcessed));
×
627
        c.attachment = runTierLabeled("attachment", graph,
×
628
                attachmentValidationUpdate(graph, lastProcessed));
×
629
        c.maintainer = runTierLabeled("maintainer", graph, nonAdminTierUpdate(graph, lastProcessed,
×
630
                GEN.MAINTAINER_ROLE, PUBLISHER_IS_ADMIN));
631
        // Member tier: admin OR maintainer publisher — split into two simpler updates
632
        // so the query planner doesn't struggle with the UNION.
633
        c.member = runTierLabeled("member(admin-pub)", graph, nonAdminTierUpdate(graph, lastProcessed,
×
634
                GEN.MEMBER_ROLE, PUBLISHER_IS_ADMIN));
635
        c.member += runTierLabeled("member(maint-pub)", graph, nonAdminTierUpdate(graph, lastProcessed,
×
636
                GEN.MEMBER_ROLE, publisherIsTieredRole(GEN.MAINTAINER_ROLE)));
×
637
        // Observer tier: self-evidence OR a downward grant from any higher tier.
638
        // ObserverRole is the default tier when a role definition omits an
639
        // explicit subclass (see "Role types" in design-space-repositories.md), so
640
        // most "X assigned Y this role" nanopubs land here. Restricting the tier
641
        // to PUBLISHER_IS_SELF would silently drop those grants. The four
642
        // sub-loops mirror the trust-state's downward-only chain: admin grants
643
        // anything; maintainers and members grant observer; everyone may
644
        // self-attest.
645
        c.observer = runTierLabeled("observer(admin-pub)", graph, nonAdminTierUpdate(graph, lastProcessed,
×
646
                GEN.OBSERVER_ROLE, PUBLISHER_IS_ADMIN));
647
        c.observer += runTierLabeled("observer(maint-pub)", graph, nonAdminTierUpdate(graph, lastProcessed,
×
648
                GEN.OBSERVER_ROLE, publisherIsTieredRole(GEN.MAINTAINER_ROLE)));
×
649
        c.observer += runTierLabeled("observer(member-pub)", graph, nonAdminTierUpdate(graph, lastProcessed,
×
650
                GEN.OBSERVER_ROLE, publisherIsTieredRole(GEN.MEMBER_ROLE)));
×
651
        c.observer += runTierLabeled("observer(self)", graph, nonAdminTierUpdate(graph, lastProcessed,
×
652
                GEN.OBSERVER_ROLE, PUBLISHER_IS_SELF));
653
        return c;
×
654
    }
655

656
    /**
657
     * Builds a publisher constraint requiring the publisher to be a validated holder
658
     * of the given tier's role (maintainer or member) in the target space.
659
     * Owns its own AccountState resolution so ?publisher is bound through the
660
     * targeted (pkh → agent) lookup rather than enumerated.
661
     */
662
    private static String publisherIsTieredRole(IRI tierClass) {
663
        // Re-keyed on the assignment's ref (alias → canonical already resolved by the
664
        // attachment tier). Relies on materialized non-admin RIs carrying their role
665
        // property (npa:regularProperty / npa:inverseProperty) — supplied by the
666
        // enrichment in nonAdminTierUpdate; without it this constraint matched nothing.
667
        return """
×
668
                ?acct a npa:AccountState ;
669
                      npa:pubkey ?pkh ;
670
                      npa:agent  ?publisher .
671
                ?tierRI a gen:RoleInstantiation ;
672
                        npa:forSpaceRef ?spaceRef ;
673
                        npa:forAgent ?publisher .
674
                ?rdT a npa:RoleDeclaration ;
675
                     npa:hasRoleType <%1$s> .
676
                { ?tierRI npa:regularProperty ?predT . ?rdT gen:hasRegularProperty ?predT . }
677
                UNION
678
                { ?tierRI npa:inverseProperty ?predT . ?rdT gen:hasInverseProperty ?predT . }
679
                """.formatted(tierClass);
×
680
    }
681

682
    /** Wraps {@link #runTierLoop} with tier-name context for logs/exceptions. */
683
    private int runTierLabeled(String tier, IRI graph, String sparqlUpdate) {
684
        try {
685
            return runTierLoop(graph, sparqlUpdate);
×
686
        } catch (RuntimeException ex) {
×
687
            logger.error("AuthorityResolver: tier={} failed with SPARQL UPDATE:\n{}\n", tier, sparqlUpdate, ex);
×
688
            throw ex;
×
689
        }
690
    }
691

692
    /**
693
     * Runs a single tier's INSERT to fixed point. Counts rows by probing
694
     * graph size before/after each INSERT; stops when the size doesn't change.
695
     *
696
     * @return total number of triples inserted by this tier across all iterations
697
     */
698
    int runTierLoop(IRI graph, String sparqlUpdate) {
699
        int total = 0;
×
700
        long before = graphSize(graph);
×
701
        while (true) {
702
            // Note: no explicit transaction wrapping here. In tests we observed that
703
            // HTTPRepository's RDF4J-transaction protocol silently no-op'd cross-graph
704
            // SPARQL UPDATEs with UNION sub-patterns inside conn.begin()/commit(),
705
            // while the same UPDATE POSTed directly to /statements applied correctly.
706
            // A bare prepareUpdate().execute() takes the direct /statements path and
707
            // runs the UPDATE atomically per SPARQL 1.1 semantics — which is all we
708
            // need; there's nothing else to commit atomically alongside the UPDATE.
709
            try (RepositoryConnection conn = TripleStore.get().getRepoConnection(SPACES_REPO)) {
×
710
                conn.prepareUpdate(QueryLanguage.SPARQL, sparqlUpdate).execute();
×
711
            }
712
            long after = graphSize(graph);
×
713
            long added = after - before;
×
714
            if (added <= 0) break;
×
715
            total += added;
×
716
            before = after;
×
717
        }
×
718
        return total;
×
719
    }
720

721
    private long graphSize(IRI graph) {
722
        try (RepositoryConnection conn = TripleStore.get().getRepoConnection(SPACES_REPO)) {
×
723
            return conn.size(graph);
×
724
        }
725
    }
726

727
    /**
728
     * Distinct-subject totals in the given space-state graph, broken down by
729
     * RoleInstantiation kind (admin-pinned vs not) and RoleAssignment.
730
     * Three SELECT-COUNT queries — cheap, called once per build/cycle for
731
     * the user-facing log line. Returns zeros on failure (logged) so a flaky
732
     * count read can't wedge the cycle.
733
     */
734
    TierSubjectTotals computeTierSubjectTotals(IRI graph) {
735
        long adminRIs       = countDistinctSubjects(graph, """
×
736
                ?ri a gen:RoleInstantiation ; npa:inverseProperty gen:hasAdmin .
737
                """, "ri");
738
        long attachmentRAs  = countDistinctSubjects(graph, """
×
739
                ?ra a gen:RoleAssignment .
740
                """, "ra");
741
        long nonAdminRIs    = countDistinctSubjects(graph, """
×
742
                ?ri a gen:RoleInstantiation .
743
                FILTER NOT EXISTS { ?ri npa:inverseProperty gen:hasAdmin }
744
                """, "ri");
745
        return new TierSubjectTotals(adminRIs, attachmentRAs, nonAdminRIs);
×
746
    }
747

748
    private long countDistinctSubjects(IRI graph, String wherePattern, String varName) {
749
        String query = String.format("""
×
750
                PREFIX npa: <%1$s>
751
                PREFIX gen: <%2$s>
752
                SELECT (COUNT(DISTINCT ?%3$s) AS ?n) WHERE {
753
                  GRAPH <%4$s> {
754
                    %5$s
755
                  }
756
                }
757
                """, NPA.NAMESPACE, GEN.NAMESPACE, varName, graph, wherePattern);
758
        try (RepositoryConnection conn = TripleStore.get().getRepoConnection(SPACES_REPO);
×
759
             TupleQueryResult r = conn.prepareTupleQuery(QueryLanguage.SPARQL, query).evaluate()) {
×
760
            if (!r.hasNext()) return 0;
×
761
            return Long.parseLong(r.next().getBinding("n").getValue().stringValue());
×
762
        } catch (Exception ex) {
×
763
            logger.warn("AuthorityResolver: countDistinctSubjects on {} failed: {}",
×
764
                    graph, ex.toString());
×
765
            return 0;
×
766
        }
767
    }
768

769
    // ---------------- SPARQL templates ----------------
770

771
    /**
772
     * Reusable invalidation filter on a bound nanopub-IRI variable. Pass the bare
773
     * variable name (no leading {@code ?}); e.g. {@code invalidationFilter("np")}
774
     * produces an outer-scoped {@code FILTER NOT EXISTS { GRAPH npa:graph
775
     * { ?_inv_np npx:invalidates ?np . } }}.
776
     *
777
     * <p>Joins on the raw {@code npx:invalidates} triple in {@code npa:graph},
778
     * which {@link com.knowledgepixels.query.NanopubLoader} writes into the
779
     * spaces repo from two complementary directions, making the filter symmetric
780
     * in load order:
781
     * <ul>
782
     *   <li>At the invalidator's own load: the loader's space-repo trigger fires
783
     *       whenever the nanopub has either its own space-relevant extractions
784
     *       OR an {@code npx:invalidates}/{@code npx:retracts}/{@code npx:supersedes}
785
     *       triple, so a pure-retraction nanopub still lands its raw triple plus
786
     *       {@code npa:hasLoadNumber} stamp in {@code npa:graph}.</li>
787
     *   <li>At the invalidated target's load (when the invalidator landed
788
     *       earlier): {@code NanopubLoader.getInvalidatingStatements} reads the
789
     *       triple back from the meta repo and mirrors it into the target's own
790
     *       write to the spaces repo.</li>
791
     * </ul>
792
     *
793
     * <p>The earlier shape joined on a structured {@code npa:Invalidation} entry
794
     * in {@code npa:spacesGraph} that was only emitted on the invalidator's side
795
     * AND only when the invalidated target's meta had already loaded, leaving a
796
     * window where a superseding nanopub loaded before its target produced no
797
     * entry and the stale row was never filtered out (see also the matching
798
     * change in the tier-specific {@code *InvalidationCheckWhere}/{@code
799
     * *InvalidationDelete} templates below).
800
     *
801
     * <p>Important: this filter must be placed OUTSIDE the surrounding
802
     * {@code GRAPH npa:spacesGraph { ... }} block, not nested inside it. When
803
     * nested, RDF4J's planner couples the FILTER NOT EXISTS evaluation into the
804
     * join order (per-row scan multiplied by the candidate set), which we
805
     * measured turning a 39ms query into a 60s+ timeout on the live observer-tier
806
     * data. Outside the GRAPH block, the planner defers the filter until
807
     * {@code ?np}/{@code ?rdNp} are bound and does a targeted index lookup.
808
     *
809
     * <p>Variable names must match {@code [A-Za-z0-9_]+} per SPARQL grammar —
810
     * embedding a {@code ?} inside {@code ?_inv_?np} would yield a parse error.
811
     */
812
    private static String invalidationFilter(String bareVarName) {
813
        return "FILTER NOT EXISTS { GRAPH <" + NPA.GRAPH + "> {"
30✔
814
                + " ?_inv_" + bareVarName
815
                + " <" + NPX.INVALIDATES + "> ?" + bareVarName + " . "
816
                + samePublisherClause("_inv_" + bareVarName, bareVarName)
6✔
817
                + " } }";
818
    }
819

820
    /**
821
     * SPARQL triple pair (placed inside a {@code GRAPH npa:graph { ... }} block)
822
     * requiring the invalidating nanopub and its target to share a signing public
823
     * key — the self-retraction authority gate for issue #112. Without it, the
824
     * materializer honors {@code npx:invalidates}/{@code retracts}/{@code supersedes}
825
     * from <em>any</em> validly-signed nanopub, so any agent can erase another
826
     * space's materialized state (griefing/DoS of the view — fail-closed, no
827
     * privilege escalation, but real). Additions are already admin-gated; this is
828
     * the symmetric gate on removals.
829
     *
830
     * <p>Both {@code npa:hasValidSignatureForPublicKeyHash} triples live in
831
     * {@code npa:graph} of the spaces repo: the target via its own space-load, the
832
     * invalidator via the symmetric retractor propagation in
833
     * {@link com.knowledgepixels.query.NanopubLoader} (forward {@code
834
     * loadInvalidateStatements} + reverse {@code loadInvalidatorIntoSpacesRepo}),
835
     * so the join is populated regardless of load order.
836
     *
837
     * <p>"Same pubkey" is intentionally stricter than "same agent": a retraction
838
     * signed by a different key the author owns (key rotation) is not honored, and
839
     * cross-admin supersession is out of scope here (would need an admin-authority
840
     * arm). The pubkey-bridge variable is suffixed with {@code targetVar} so two
841
     * filters in one query (e.g. on {@code ?np} and {@code ?rdNp}) don't collide.
842
     *
843
     * @param invVar    invalidator nanopub variable name (no leading {@code ?})
844
     * @param targetVar invalidated-target nanopub variable name (no leading {@code ?})
845
     */
846
    private static String samePublisherClause(String invVar, String targetVar) {
847
        String pk = "?_invpk_" + targetVar;
9✔
848
        return "?" + invVar + " <" + NPA.HAS_VALID_SIGNATURE_FOR_PUBLIC_KEY_HASH + "> " + pk + " . "
30✔
849
                + "?" + targetVar + " <" + NPA.HAS_VALID_SIGNATURE_FOR_PUBLIC_KEY_HASH + "> " + pk + " .";
850
    }
851

852
    /**
853
     * Admin tier: seed from {@code npadef:...hasRootAdmin} (trusted by construction)
854
     * plus closed-over admin grants; insert any {@code gen:RoleInstantiation} with
855
     * {@code npa:inverseProperty gen:hasAdmin} whose publisher (resolved via mirrored
856
     * trust-approved AccountState) is already in the admin set.
857
     *
858
     * <p>The seed is gated by {@link #spaceRefAliveFilter} (not the per-nanopub
859
     * {@code invalidationFilter("defNp")}): the {@code hasRootAdmin} seed is anchored
860
     * to the root NPID, which is the immutable space-ref identity, so superseding the
861
     * root <em>nanopub</em> with a continuation revision must not strip the seed —
862
     * only retracting every definition of the ref removes it. See issue #110.
863
     */
864
    static String adminTierUpdate(IRI graph, long lastProcessed) {
865
        // Order tuned for RDF4J's evaluator:
866
        //   1. Anchor on the small (seed UNION closed-over) set to bind ?publisher
867
        //      and ?space cheaply.
868
        //   2. Resolve ?pkh from the mirrored AccountState row (?publisher bound).
869
        //   3. Probe instantiations using the now-bound (?space, ?pkh) — targeted
870
        //      lookup, not a full RoleInstantiation scan.
871
        //   4. Load-number filter on bound ?np.
872
        //   5. Dedup at the end.
873
        // Authority is keyed on the space *ref* (npa:forSpaceRef), not the bare Space
874
        // IRI: two refs that share an IRI but have different roots are independent
875
        // domains (see doc/design-spaceref-isolation.md). The instantiation evidence in
876
        // the extraction graph is IRI-keyed (a gen:hasAdmin nanopub names the bare IRI),
877
        // so we project it per-ref by joining each instantiation naming ?space to the
878
        // admin rows of every ref of ?space whose admin set contains the publisher. The
879
        // inserted subject is minted per (?ri, ?spaceRef) so one instantiation validating
880
        // into N refs yields N distinct rows. TRANSITIONAL-DUAL-EMIT (Phase 4: remove):
881
        // forSpace is still emitted alongside forSpaceRef so the not-yet-migrated
882
        // downstream tiers / pre-ref read queries keep functioning on a mixed-version
883
        // fleet; it is dropped once everything keys on forSpaceRef.
884
        return """
69✔
885
                PREFIX npa:  <%1$s>
886
                PREFIX gen:  <%2$s>
887
                INSERT { GRAPH <%3$s> {
888
                  ?sri a gen:RoleInstantiation ;
889
                       npa:forSpaceRef ?spaceRef ;
890
                       npa:forSpace ?space ;
891
                       npa:inverseProperty gen:hasAdmin ;
892
                       # Stamp the admin tier so consumers read tier uniformly across all
893
                       # RoleInstantiations (?ri npa:hasRoleType ?tier) with no admin
894
                       # special-case — matching the non-admin path (issue #125, #127).
895
                       npa:hasRoleType gen:AdminRole ;
896
                       npa:forAgent ?agent ;
897
                       npa:viaNanopub ?np .
898
                } }
899
                WHERE {
900
                  # 1. Anchor: who is already an admin of which space ref?
901
                  {
902
                    # Seed branch: root-admin of a space ref that is still alive
903
                    # (has at least one non-invalidated definition). NOT filtered on
904
                    # ?def's own invalidation — superseding the root nanopub with a
905
                    # continuation revision must keep the seed; only a fully-retracted
906
                    # ref drops it (issue #110).
907
                    GRAPH <%4$s> {
908
                      ?def a npa:SpaceDefinition ;
909
                           npa:forSpaceRef  ?spaceRef ;
910
                           npa:hasRootAdmin ?publisher .
911
                      ?spaceRef npa:spaceIri ?space .
912
                    }
913
                    %7$s
914
                  }
915
                  UNION
916
                  {
917
                    # Closed-over branch: an existing admin of this ref. Recurse on the
918
                    # ref, then resolve its bare IRI to probe the IRI-keyed instantiation.
919
                    GRAPH <%3$s> {
920
                      ?prev a gen:RoleInstantiation ;
921
                            npa:forSpaceRef     ?spaceRef ;
922
                            npa:inverseProperty gen:hasAdmin ;
923
                            npa:forAgent        ?publisher .
924
                    }
925
                    GRAPH <%4$s> {
926
                      ?spaceRef npa:spaceIri ?space .
927
                    }
928
                  }
929
                  # 2. Mirror: resolve ?publisher → ?pkh via the trust-approved row.
930
                  GRAPH <%3$s> {
931
                    ?acct a npa:AccountState ;
932
                          npa:agent  ?publisher ;
933
                          npa:pubkey ?pkh .
934
                  }
935
                  # 3. Targeted instantiation lookup by space + pubkey (IRI-keyed).
936
                  GRAPH <%4$s> {
937
                    ?ri a gen:RoleInstantiation ;
938
                        npa:forSpace        ?space ;
939
                        npa:inverseProperty gen:hasAdmin ;
940
                        npa:forAgent        ?agent ;
941
                        npa:pubkeyHash      ?pkh ;
942
                        npa:viaNanopub      ?np .
943
                  }
944
                  # 3a. Mint the per-ref state subject: (?ri, ?spaceRef) → ?sri.
945
                  BIND(IRI(CONCAT(STR(?ri), "__", ENCODE_FOR_URI(STR(?spaceRef)))) AS ?sri)
946
                  %6$s
947
                  # 4. Load-number filter on bound ?np.
948
                  GRAPH <%8$s> {
949
                    ?np npa:hasLoadNumber ?ln .
950
                    FILTER (?ln > %5$d)
951
                  }
952
                  # 5. Dedup last — keyed on (ref, agent).
953
                  FILTER NOT EXISTS { GRAPH <%3$s> {
954
                    ?existing a gen:RoleInstantiation ;
955
                              npa:forSpaceRef ?spaceRef ;
956
                              npa:forAgent ?agent ;
957
                              npa:inverseProperty gen:hasAdmin .
958
                  } }
959
                }
960
                """.formatted(
3✔
961
                NPA.NAMESPACE,
962
                GEN.NAMESPACE,
963
                graph,
964
                SpacesVocab.SPACES_GRAPH,
965
                lastProcessed,
15✔
966
                invalidationFilter("np"),
12✔
967
                spaceRefAliveFilter(),
18✔
968
                NPA.GRAPH);
969
    }
970

971
    /**
972
     * Seed-survival filter for the admin tier (issue #110). The {@code hasRootAdmin}
973
     * seed is anchored to the root NPID, which is the immutable space-ref identity, so
974
     * it must survive supersession of the root <em>nanopub</em> by a continuation
975
     * revision (a later definition re-roots to the same ref via
976
     * {@code gen:hasRootDefinition} and so carries no {@code hasRootAdmin} of its own).
977
     * The previous {@code invalidationFilter("defNp")} dropped the seed the moment the
978
     * root revision was superseded, leaving the whole admin closure — and everything
979
     * cascading from it — unmaterialized for any space whose definition had ever been
980
     * updated.
981
     *
982
     * <p>Expressed positively: the seed survives iff the space ref still has at least
983
     * one non-invalidated {@link SpacesVocab#SPACE_DEFINITION}. A fully-retracted ref
984
     * (every definition invalidated) has no live definition, so the {@code FILTER
985
     * EXISTS} fails and the seed correctly disappears. Anchored on the already-bound
986
     * {@code ?spaceRef}, so it's a targeted lookup over that ref's (few) definitions.
987
     */
988
    private static String spaceRefAliveFilter() {
989
        return """
33✔
990
                FILTER EXISTS {
991
                  GRAPH <%1$s> {
992
                    ?liveDef a npa:SpaceDefinition ;
993
                             npa:forSpaceRef ?spaceRef ;
994
                             npa:viaNanopub  ?liveNp .
995
                  }
996
                  %2$s
997
                }
998
                """.formatted(SpacesVocab.SPACES_GRAPH, invalidationFilter("liveNp"));
9✔
999
    }
1000

1001
    /**
1002
     * {@code gen:hasRole} attachment validation: an attachment is validated iff its
1003
     * publisher is already a validated admin of the target space. Adds
1004
     * {@code gen:RoleAssignment} rows to the space-state graph.
1005
     */
1006
    static String attachmentValidationUpdate(IRI graph, long lastProcessed) {
1007
        // Ref-keyed (see doc/design-spaceref-isolation.md). The attachment names a bare
1008
        // Space IRI; it is validated per-ref for every ref of that IRI whose admin set
1009
        // contains the publisher (direct), or — when the named IRI is an owl:sameAs alias
1010
        // — for the canonical ref it maps to (issue #113). ?targetRef is the ref the
1011
        // RoleAssignment attaches to; the inserted subject is minted per (?ra, ?targetRef)
1012
        // so one attachment validating into N refs yields N distinct rows.
1013
        // TRANSITIONAL-DUAL-EMIT (Phase 4: remove): forSpace (the attached IRI, possibly an
1014
        // alias) is kept so the non-admin tier can probe the IRI-keyed instantiations
1015
        // naming it, and so pre-ref read queries keep functioning on a mixed-version fleet.
1016
        return """
69✔
1017
                PREFIX npa:  <%1$s>
1018
                PREFIX gen:  <%2$s>
1019
                INSERT { GRAPH <%3$s> {
1020
                  ?ra2 a gen:RoleAssignment ;
1021
                       npa:forSpaceRef ?targetRef ;
1022
                       npa:forSpace ?space ;
1023
                       gen:hasRole  ?role ;
1024
                       npa:viaNanopub ?np .
1025
                } }
1026
                WHERE {
1027
                  GRAPH <%4$s> {
1028
                    ?ra a gen:RoleAssignment ;
1029
                        npa:forSpace ?space ;
1030
                        gen:hasRole  ?role ;
1031
                        npa:pubkeyHash ?pkh ;
1032
                        npa:viaNanopub ?np .
1033
                  }
1034
                  GRAPH <%7$s> {
1035
                    ?np npa:hasLoadNumber ?ln .
1036
                    FILTER (?ln > %5$d)
1037
                  }
1038
                  GRAPH <%3$s> {
1039
                    ?acct a npa:AccountState ;
1040
                          npa:agent  ?publisher ;
1041
                          npa:pubkey ?pkh .
1042
                  }
1043
                  # Per-ref admin gate. ?targetRef = a ref of ?space the publisher admins
1044
                  # (direct), or the canonical ref ?space is an owl:sameAs alias of.
1045
                  {
1046
                    GRAPH <%4$s> { ?targetRef npa:spaceIri ?space . }
1047
                    GRAPH <%3$s> {
1048
                      ?adminRI a gen:RoleInstantiation ;
1049
                               npa:forSpaceRef ?targetRef ;
1050
                               npa:inverseProperty gen:hasAdmin ;
1051
                               npa:forAgent ?publisher .
1052
                    }
1053
                  }
1054
                  UNION
1055
                  {
1056
                    GRAPH <%3$s> {
1057
                      ?space npa:sameAsSpace ?targetRef .
1058
                      ?adminRI a gen:RoleInstantiation ;
1059
                               npa:forSpaceRef ?targetRef ;
1060
                               npa:inverseProperty gen:hasAdmin ;
1061
                               npa:forAgent ?publisher .
1062
                    }
1063
                  }
1064
                  BIND(IRI(CONCAT(STR(?ra), "__", ENCODE_FOR_URI(STR(?targetRef)))) AS ?ra2)
1065
                  %6$s
1066
                  FILTER NOT EXISTS { GRAPH <%3$s> {
1067
                    ?existing a gen:RoleAssignment ;
1068
                              npa:forSpaceRef ?targetRef ;
1069
                              gen:hasRole  ?role .
1070
                  } }
1071
                }
1072
                """.formatted(
3✔
1073
                NPA.NAMESPACE,
1074
                GEN.NAMESPACE,
1075
                graph,
1076
                SpacesVocab.SPACES_GRAPH,
1077
                lastProcessed,
15✔
1078
                invalidationFilter("np"),
18✔
1079
                NPA.GRAPH);
1080
    }
1081

1082
    /**
1083
     * Preset-bundled role materialization (Nanodash issue #302). For each active,
1084
     * admin-authored {@code gen:PresetAssignment} targeting a {@code gen:Space}, inserts
1085
     * one {@code gen:RoleAssignment} per role the preset bundles — exactly as if
1086
     * {@code <space> gen:hasRole <role>} had been published by the assignment's publisher.
1087
     * The materialized rows carry {@code npa:derivedFromPreset} (the assignment nanopub)
1088
     * so the deactivation delete and read-side marking can scope to them without touching
1089
     * directly-published attachments. See {@code doc/design-preset-role-materialization.md}.
1090
     *
1091
     * <p>Activation is resolved by an <b>authorization-scoped latest-wins</b> over the
1092
     * {@code (preset, resource)} pair, NOT {@code npx:invalidates} (§3): the candidate set
1093
     * for the {@code MAX(dct:created)} comparison is restricted to assignments whose
1094
     * publisher is also a validated admin of the target ref, so an unauthorized key's newer
1095
     * assignment cannot shadow an admin's activation (the #113-class anti-hijack rule).
1096
     */
1097
    static String presetAttachmentValidationUpdate(IRI graph, long lastProcessed) {
1098
        // Ref-keyed like attachmentValidationUpdate: the assignment names a bare resource
1099
        // IRI; it is validated per-ref for every Space ref of that IRI whose admin set
1100
        // contains the publisher. The inserted subject is minted per (assignment, ref, role)
1101
        // — one assignment fans out to N roles and N refs. Non-Space targets resolve no
1102
        // ?targetRef and so insert nothing (correct no-op; maintained-resource / individual
1103
        // targets are future work, see design doc §2). TRANSITIONAL-DUAL-EMIT (Phase 4:
1104
        // remove): forSpace kept alongside forSpaceRef so the non-admin tiers can probe the
1105
        // IRI-keyed instantiations and pre-ref read queries keep functioning.
1106
        return """
69✔
1107
                PREFIX npa:  <%1$s>
1108
                PREFIX gen:  <%2$s>
1109
                INSERT { GRAPH <%3$s> {
1110
                  ?ra2 a gen:RoleAssignment ;
1111
                       npa:forSpaceRef ?targetRef ;
1112
                       npa:forSpace    ?resource ;
1113
                       gen:hasRole     ?role ;
1114
                       npa:viaNanopub  ?assignNp ;
1115
                       npa:derivedFromPreset ?assignNp .
1116
                } }
1117
                WHERE {
1118
                  # 1. Anchor: active preset assignments in the extraction graph.
1119
                  GRAPH <%4$s> {
1120
                    ?pa a npa:PresetAssignment ;
1121
                        npa:ofPreset    ?preset ;
1122
                        npa:forResource ?resource ;
1123
                        npa:isActivated true ;
1124
                        npa:pubkeyHash  ?pkh ;
1125
                        npa:viaNanopub  ?assignNp ;
1126
                        <http://purl.org/dc/terms/created> ?created .
1127
                  }
1128
                  # 2. Load-number filter on the assignment nanopub.
1129
                  GRAPH <%7$s> {
1130
                    ?assignNp npa:hasLoadNumber ?ln .
1131
                    FILTER (?ln > %5$d)
1132
                  }
1133
                  # 3. Resolve publisher pkh -> agent via the mirrored trust-approved row.
1134
                  GRAPH <%3$s> {
1135
                    ?acct a npa:AccountState ;
1136
                          npa:agent  ?publisher ;
1137
                          npa:pubkey ?pkh .
1138
                  }
1139
                  # 4. Target must be a Space ref the publisher admins — direct, or the
1140
                  #    canonical ref ?resource is an owl:sameAs alias of (issue #113 parity
1141
                  #    with attachmentValidationUpdate, so a preset assigned against an alias
1142
                  #    IRI still materializes against the canonical ref).
1143
                  {
1144
                    GRAPH <%4$s> { ?targetRef npa:spaceIri ?resource . }
1145
                    GRAPH <%3$s> {
1146
                      ?adminRI a gen:RoleInstantiation ;
1147
                               npa:forSpaceRef ?targetRef ;
1148
                               npa:inverseProperty gen:hasAdmin ;
1149
                               npa:forAgent ?publisher .
1150
                    }
1151
                  }
1152
                  UNION
1153
                  {
1154
                    GRAPH <%3$s> {
1155
                      ?resource npa:sameAsSpace ?targetRef .
1156
                      ?adminRI a gen:RoleInstantiation ;
1157
                               npa:forSpaceRef ?targetRef ;
1158
                               npa:inverseProperty gen:hasAdmin ;
1159
                               npa:forAgent ?publisher .
1160
                    }
1161
                  }
1162
                  # 5. Resolve the assignment's referenced preset IRI (node or kind) to its
1163
                  #    canonical kind, mirroring how Nanodash views key on dct:isVersionOf
1164
                  #    (ViewDisplay.getViewKindIri). Every declaration carries npa:ofPreset for
1165
                  #    both its node IRI and kind, so either reference maps to the same ?kind.
1166
                  GRAPH <%4$s> {
1167
                    ?pdMap a npa:PresetDeclaration ;
1168
                           npa:ofPreset   ?preset ;
1169
                           npa:presetKind ?kind .
1170
                  }
1171
                  # 5a. Roles come from the LATEST live declaration of that kind, restricted to
1172
                  #     Space-targeted presets — so a superseded preset version's roles never leak
1173
                  #     (the per-view-kind latest-wins, ported to materialization).
1174
                  GRAPH <%4$s> {
1175
                    ?pd a npa:PresetDeclaration ;
1176
                        npa:presetKind           ?kind ;
1177
                        npa:presetRole           ?role ;
1178
                        npa:appliesToInstancesOf gen:Space ;
1179
                        npa:viaNanopub           ?pdNp ;
1180
                        <http://purl.org/dc/terms/created> ?pdCreated .
1181
                  }
1182
                  # 5b. Latest-declaration-per-kind: reject if a newer LIVE declaration of the
1183
                  #     same kind exists (tiebreak on subject IRI for equal timestamps).
1184
                  FILTER NOT EXISTS {
1185
                    GRAPH <%4$s> {
1186
                      ?pdNewer a npa:PresetDeclaration ;
1187
                               npa:presetKind ?kind ;
1188
                               npa:viaNanopub ?pdNpNewer ;
1189
                               <http://purl.org/dc/terms/created> ?pdCreatedNewer .
1190
                      FILTER (?pdCreatedNewer > ?pdCreated
1191
                              || (?pdCreatedNewer = ?pdCreated && STR(?pdNewer) > STR(?pd)))
1192
                    }
1193
                    %8$s
1194
                  }
1195
                  # 5c. The chosen declaration must itself be live (not superseded/retracted).
1196
                  %9$s
1197
                  # 6. Mint the per (assignment, ref, role) subject.
1198
                  BIND(IRI(CONCAT(STR(?pa), "__", ENCODE_FOR_URI(STR(?targetRef)),
1199
                                  "__", ENCODE_FOR_URI(STR(?role)))) AS ?ra2)
1200
                  # 7. Authorization-scoped latest-wins (anti-hijack, design doc §3): reject
1201
                  #    if a newer same-(preset,resource) assignment exists whose publisher is
1202
                  #    ALSO a validated admin of ?targetRef. Filtering the shadowing candidate
1203
                  #    to admin-authored rows BEFORE taking the latest is what stops an
1204
                  #    unauthorized key from suppressing an admin's activation. Placed after
1205
                  #    the main vars are bound so the planner defers it (RDF4J quirk).
1206
                  FILTER NOT EXISTS {
1207
                    GRAPH <%4$s> {
1208
                      ?paNewer a npa:PresetAssignment ;
1209
                               npa:ofPreset    ?preset ;
1210
                               npa:forResource ?resource ;
1211
                               npa:pubkeyHash  ?pkhNewer ;
1212
                               <http://purl.org/dc/terms/created> ?createdNewer .
1213
                      FILTER (?createdNewer > ?created
1214
                              || (?createdNewer = ?created && STR(?paNewer) > STR(?pa)))
1215
                    }
1216
                    GRAPH <%3$s> {
1217
                      ?acctNewer a npa:AccountState ;
1218
                                 npa:agent  ?publisherNewer ;
1219
                                 npa:pubkey ?pkhNewer .
1220
                      ?adminRINewer a gen:RoleInstantiation ;
1221
                                    npa:forSpaceRef ?targetRef ;
1222
                                    npa:inverseProperty gen:hasAdmin ;
1223
                                    npa:forAgent ?publisherNewer .
1224
                    }
1225
                  }
1226
                  # 8. Defensive: drop if the assignment nanopub itself was hard-retracted.
1227
                  %6$s
1228
                  # 9. Dedup last — keyed on (ref, role).
1229
                  FILTER NOT EXISTS { GRAPH <%3$s> {
1230
                    ?existing a gen:RoleAssignment ;
1231
                              npa:forSpaceRef ?targetRef ;
1232
                              gen:hasRole ?role .
1233
                  } }
1234
                }
1235
                """.formatted(
3✔
1236
                NPA.NAMESPACE,
1237
                GEN.NAMESPACE,
1238
                graph,
1239
                SpacesVocab.SPACES_GRAPH,
1240
                lastProcessed,
15✔
1241
                invalidationFilter("assignNp"),
27✔
1242
                NPA.GRAPH,
1243
                invalidationFilter("pdNpNewer"),
15✔
1244
                invalidationFilter("pdNp"));
6✔
1245
    }
1246

1247
    /**
1248
     * Stamps a ref-scoped, admin-validated mirror of each {@code npa:PresetAssignment}
1249
     * into the state graph (issue #122). The publisher-agnostic extraction row
1250
     * ({@link SpacesExtractor#extractPresetAssignment}) is keyed only by
1251
     * {@code npa:forResource}, so a consumer listing a space's preset assignments by IRI
1252
     * sees the union across <em>all</em> refs claiming that IRI. This stamp adds
1253
     * {@code npa:forSpaceRef ?targetRef} so the "Assigned presets" listing is no longer
1254
     * merged across refs of the same IRI — the one remaining About-tab listing that still
1255
     * merged across refs (every other ref-scoped listing already has a {@code forSpaceRef}
1256
     * companion).
1257
     *
1258
     * <p>Faithful per-assignment mirror — deliberately <em>not</em> role-gated and
1259
     * <em>not</em> latest-wins-resolved, unlike {@link #presetAttachmentValidationUpdate}:
1260
     * <ul>
1261
     *   <li>No {@code npa:PresetDeclaration}/role join, so a preset that bundles only
1262
     *       <em>views</em> (no roles) is still listed.</li>
1263
     *   <li>Emits active <em>and</em> deactivated rows (carries {@code npa:isActivated})
1264
     *       so the listing can show state; a deactivation is just a newer admin-authored
1265
     *       row, so no {@code dct:created}-driven removal is needed here (contrast §4.4).</li>
1266
     *   <li>Latest-wins is deferred to the consumer query, which ranges only over these
1267
     *       admin-authored rows — so it is authorization-scoped for free (design §3): a
1268
     *       non-admin of the ref can never get a row stamped, so it cannot enter the
1269
     *       latest-wins race.</li>
1270
     * </ul>
1271
     *
1272
     * <p>Display-only leaf: nothing downstream derives from these rows (contrast the
1273
     * preset-derived {@code gen:RoleAssignment}), so the caller must <em>not</em> feed this
1274
     * tier's count into {@code structuralAdds}. The {@code npa:forSpaceRef} predicate also
1275
     * distinguishes a stamped row from the IRI-keyed extraction row (which never carries it),
1276
     * so {@link #presetAssignmentRefInvalidationDelete} can target exactly these rows.
1277
     * Reuses steps 1–4 of {@link #presetAttachmentValidationUpdate}; see
1278
     * doc/design-preset-role-materialization.md §3 and issue #122.
1279
     */
1280
    static String presetAssignmentRefStampUpdate(IRI graph, long lastProcessed) {
1281
        return """
69✔
1282
                PREFIX npa:  <%1$s>
1283
                PREFIX gen:  <%2$s>
1284
                INSERT { GRAPH <%3$s> {
1285
                  ?paRef a npa:PresetAssignment ;
1286
                         npa:ofPreset    ?preset ;
1287
                         npa:forResource ?resource ;
1288
                         npa:forSpaceRef ?targetRef ;
1289
                         npa:isActivated ?activated ;
1290
                         npa:viaNanopub  ?assignNp ;
1291
                         <http://purl.org/dc/terms/created> ?created .
1292
                } }
1293
                WHERE {
1294
                  # 1. Anchor: every assignment row (active or not) in the extraction graph.
1295
                  GRAPH <%4$s> {
1296
                    ?pa a npa:PresetAssignment ;
1297
                        npa:ofPreset    ?preset ;
1298
                        npa:forResource ?resource ;
1299
                        npa:isActivated ?activated ;
1300
                        npa:pubkeyHash  ?pkh ;
1301
                        npa:viaNanopub  ?assignNp ;
1302
                        <http://purl.org/dc/terms/created> ?created .
1303
                  }
1304
                  # 2. Load-number filter on the assignment nanopub (delta window).
1305
                  GRAPH <%6$s> {
1306
                    ?assignNp npa:hasLoadNumber ?ln .
1307
                    FILTER (?ln > %5$d)
1308
                  }
1309
                  # 3. Resolve publisher pkh -> agent via the mirrored trust-approved row.
1310
                  GRAPH <%3$s> {
1311
                    ?acct a npa:AccountState ;
1312
                          npa:agent  ?publisher ;
1313
                          npa:pubkey ?pkh .
1314
                  }
1315
                  # 4. Target must be a Space ref the publisher admins. ?targetRef = that ref;
1316
                  #    fan-out to N refs the publisher admins (per-ref isolation, consistent
1317
                  #    with the role materializer and design-spaceref-isolation.md). Direct,
1318
                  #    or the canonical ref ?resource is an owl:sameAs alias of (issue #113),
1319
                  #    so an assignment naming an alias is still listed under the canonical ref.
1320
                  {
1321
                    GRAPH <%4$s> { ?targetRef npa:spaceIri ?resource . }
1322
                    GRAPH <%3$s> {
1323
                      ?adminRI a gen:RoleInstantiation ;
1324
                               npa:forSpaceRef ?targetRef ;
1325
                               npa:inverseProperty gen:hasAdmin ;
1326
                               npa:forAgent ?publisher .
1327
                    }
1328
                  }
1329
                  UNION
1330
                  {
1331
                    GRAPH <%3$s> {
1332
                      ?resource npa:sameAsSpace ?targetRef .
1333
                      ?adminRI a gen:RoleInstantiation ;
1334
                               npa:forSpaceRef ?targetRef ;
1335
                               npa:inverseProperty gen:hasAdmin ;
1336
                               npa:forAgent ?publisher .
1337
                    }
1338
                  }
1339
                  # 5. Defensive: drop if the assignment nanopub itself was hard-retracted.
1340
                  %7$s
1341
                  # 6. Mint per (assignment, ref); dedup on the bound subject. No latest-wins
1342
                  #    here — a deactivation is just a newer admin-authored row, and the
1343
                  #    consumer resolves latest dct:created per (preset,resource) over these
1344
                  #    admin-authored rows (so the resolution is authorization-scoped).
1345
                  BIND(IRI(CONCAT(STR(?pa), "__", ENCODE_FOR_URI(STR(?targetRef)))) AS ?paRef)
1346
                  FILTER NOT EXISTS { GRAPH <%3$s> { ?paRef a npa:PresetAssignment . } }
1347
                }
1348
                """.formatted(
3✔
1349
                NPA.NAMESPACE,
1350
                GEN.NAMESPACE,
1351
                graph,
1352
                SpacesVocab.SPACES_GRAPH,
1353
                lastProcessed,
27✔
1354
                NPA.GRAPH,
1355
                invalidationFilter("assignNp"));
6✔
1356
    }
1357

1358
    /**
1359
     * Non-admin tier publisher constraints (inserted as a SPARQL sub-pattern).
1360
     * Each constraint owns the AccountState (pkh → agent) lookup so the join
1361
     * variable is bound through a targeted pattern. The observer-self variant
1362
     * binds {@code npa:agent ?agent} directly — no separate {@code ?publisher}
1363
     * variable, no post-join equality filter — which lets the planner anchor
1364
     * the AccountState lookup on the already-bound {@code ?agent} instead of
1365
     * enumerating all approved publishers and filtering at the end.
1366
     */
1367
    static final String PUBLISHER_IS_ADMIN = """
1368
            ?acct a npa:AccountState ;
1369
                  npa:pubkey ?pkh ;
1370
                  npa:agent  ?publisher .
1371
            # Admin of the assignment's ref. The ref already resolves alias →
1372
            # canonical (the attachment tier bound ?spaceRef through the owl:sameAs
1373
            # alias edge for aliased IRIs, issue #113), so no alias arm is needed here.
1374
            ?adminRI a gen:RoleInstantiation ;
1375
                     npa:forSpaceRef ?spaceRef ;
1376
                     npa:inverseProperty gen:hasAdmin ;
1377
                     npa:forAgent ?publisher .
1378
            """;
1379

1380
    /** Observer self-evidence: the assignee's own pubkey signed the instantiation. */
1381
    static final String PUBLISHER_IS_SELF = """
1382
            ?acct a npa:AccountState ;
1383
                  npa:pubkey ?pkh ;
1384
                  npa:agent  ?agent .
1385
            """;
1386

1387
    /**
1388
     * Maintainer / Member / Observer tier INSERT. Same shape: find an instantiation
1389
     * whose predicate matches a RoleDeclaration of the given tier attached to the
1390
     * target space, and whose publisher passes the tier-specific constraint.
1391
     */
1392
    static String nonAdminTierUpdate(IRI graph, long lastProcessed,
1393
                                     IRI tierClass, String publisherConstraint) {
1394
        // Order tuned for RDF4J's evaluator (which executes BGPs roughly in order).
1395
        // The crucial choice is the *anchor*: instantiation-first plans send the
1396
        // planner exploring the full ~thousands of candidate RIs and only filter
1397
        // by tier at the very end. Attachment-first anchors on the small set of
1398
        // gen:RoleAssignment rows already validated in this space-state graph
1399
        // (~hundreds, often zero) and walks outward by bound (?role, ?space).
1400
        //
1401
        //   1. Anchor on RoleAssignments in this space-state graph (small).
1402
        //   1a. Resolve the IRIs that denote the assignment's ref — its canonical
1403
        //      IRI plus any validated owl:sameAs aliases — so an instantiation that
1404
        //      names an alias of the space still matches (issue #113). Bound here so
1405
        //      the instantiation lookup below stays anchored by ?instSpace.
1406
        //   2. Match the tier-pinned RoleDeclaration by ?role.
1407
        //   3. Pair role-decl direction to instantiation direction in one UNION
1408
        //      so only (reg, reg)/(inv, inv) combos are explored.
1409
        //   4. Targeted instantiation lookup — (?instSpace, ?pred) are bound.
1410
        //   5. Publisher constraint (incl. AccountState resolution).
1411
        //   6. Load-number filter on bound ?np.
1412
        //   7. Dedup at the end.
1413
        return """
69✔
1414
                PREFIX npa:  <%1$s>
1415
                PREFIX gen:  <%2$s>
1416
                INSERT { GRAPH <%3$s> {
1417
                  ?ri2 a gen:RoleInstantiation ;
1418
                       npa:forSpaceRef ?spaceRef ;
1419
                       # TRANSITIONAL-DUAL-EMIT (Phase 4: remove): forSpace alongside
1420
                       # forSpaceRef so pre-ref read queries (e.g. get-space-members) keep
1421
                       # functioning on a mixed-version fleet; downstream tiers key on the ref.
1422
                       npa:forSpace ?space ;
1423
                       npa:forAgent ?agent ;
1424
                       ?dirPred ?pred ;
1425
                       # Persist the tier and role IRI that are already bound at this point —
1426
                       # the loop's tierClass arg (%7$s) and the anchoring attachment's ?role
1427
                       # (step 1) — so ref-scoped consumers key on identity rather than
1428
                       # re-deriving the tier from the bare predicate against GLOBAL
1429
                       # RoleDeclarations. The bare-predicate re-derivation bleeds tiers
1430
                       # across spaces that declare the same predicate at different tiers
1431
                       # (issue #125): consumers should match ?ri2 npa:hasRoleType <tier>
1432
                       # / gen:hasRole ?role, exactly as the *-roles-ref queries do.
1433
                       npa:hasRoleType <%7$s> ;
1434
                       gen:hasRole ?role ;
1435
                       npa:viaNanopub ?np .
1436
                } }
1437
                WHERE {
1438
                  # 1. Anchor: validated attachments in this space-state graph (ref-keyed).
1439
                  GRAPH <%3$s> {
1440
                    ?ra a gen:RoleAssignment ;
1441
                        gen:hasRole     ?role ;
1442
                        npa:forSpaceRef ?spaceRef ;
1443
                        npa:forSpace    ?space .
1444
                  }
1445
                  # 1a. The IRIs that denote this ref: its canonical IRI, plus any validated
1446
                  #     owl:sameAs aliases of it (issue #113) — so an instantiation naming an
1447
                  #     alias of the space still materializes here. Bound BEFORE the
1448
                  #     instantiation BGP so that lookup stays anchored by ?instSpace (planner
1449
                  #     note above); ?spaceRef is already bound, so each arm is a targeted
1450
                  #     lookup yielding a tiny IRI set. The alias arm only follows admin-
1451
                  #     validated npa:sameAsSpace edges, so it grants no authority the admin
1452
                  #     tier would not (anti-hijack is enforced upstream, not relaxed here).
1453
                  {
1454
                    GRAPH <%4$s> { ?spaceRef npa:spaceIri ?instSpace . }
1455
                  }
1456
                  UNION
1457
                  {
1458
                    GRAPH <%3$s> { ?instSpace npa:sameAsSpace ?spaceRef . }
1459
                  }
1460
                  # 2. Tier-pinned RoleDeclaration (?role bound from the attachment). Its
1461
                  #    nanopub's invalidation is intentionally NOT consulted (see step 7), so
1462
                  #    no ?rdNp binding is needed.
1463
                  GRAPH <%4$s> {
1464
                    ?rd a npa:RoleDeclaration ;
1465
                        npa:hasRoleType <%7$s> ;
1466
                        npa:role        ?role .
1467
                    # 3. Pair role-decl direction to the instantiation in one UNION so only
1468
                    #    matching combos are explored, binding (?instSpace, ?agent) per arm.
1469
                    #    ?dirPred carries the resolved direction so the materialized row
1470
                    #    records the role property (read by get-space-members and
1471
                    #    publisherIsTieredRole) — identical shape whichever arm matched.
1472
                    #
1473
                    #    The first two arms handle instantiations the extractor already
1474
                    #    classified (npa:regularProperty / npa:inverseProperty). The last two
1475
                    #    resolve a custom predicate the extractor left neutral (npa:rolePredicate
1476
                    #    with raw npa:bindingSubject / npa:bindingObject): the role declaration
1477
                    #    supplies the direction, which fixes which raw endpoint is the space vs
1478
                    #    the agent. INVERSE = <space> pred <agent>; REGULAR = <agent> pred <space>.
1479
                    {
1480
                      ?rd gen:hasRegularProperty ?pred .
1481
                      ?ri npa:regularProperty ?pred ;
1482
                          npa:forSpace ?instSpace ;
1483
                          npa:forAgent ?agent .
1484
                      BIND(npa:regularProperty AS ?dirPred)
1485
                    }
1486
                    UNION
1487
                    {
1488
                      ?rd gen:hasInverseProperty ?pred .
1489
                      ?ri npa:inverseProperty ?pred ;
1490
                          npa:forSpace ?instSpace ;
1491
                          npa:forAgent ?agent .
1492
                      BIND(npa:inverseProperty AS ?dirPred)
1493
                    }
1494
                    UNION
1495
                    {
1496
                      ?rd gen:hasInverseProperty ?pred .
1497
                      ?ri npa:rolePredicate   ?pred ;
1498
                          npa:bindingSubject  ?instSpace ;
1499
                          npa:bindingObject   ?agent .
1500
                      BIND(npa:inverseProperty AS ?dirPred)
1501
                    }
1502
                    UNION
1503
                    {
1504
                      ?rd gen:hasRegularProperty ?pred .
1505
                      ?ri npa:rolePredicate   ?pred ;
1506
                          npa:bindingObject   ?instSpace ;
1507
                          npa:bindingSubject  ?agent .
1508
                      BIND(npa:regularProperty AS ?dirPred)
1509
                    }
1510
                    # 4. Common instantiation columns. ?instSpace was resolved to this ref
1511
                    #    above (canonical or owl:sameAs alias), so an alias-named instantiation
1512
                    #    joins the same ?spaceRef as a canonical one. The materialized row still
1513
                    #    carries npa:forSpace ?space (the attachment's IRI) for the transitional
1514
                    #    dual-emit, so pre-ref reads see the member under the space's primary IRI.
1515
                    ?ri a gen:RoleInstantiation ;
1516
                        npa:pubkeyHash ?pkh ;
1517
                        npa:viaNanopub ?np .
1518
                  }
1519
                  # 5. Publisher constraint (incl. AccountState resolution).
1520
                  GRAPH <%3$s> {
1521
                    %8$s
1522
                  }
1523
                  # 5a. Mint the per-ref state subject: (?ri, ?spaceRef) → ?ri2.
1524
                  BIND(IRI(CONCAT(STR(?ri), "__", ENCODE_FOR_URI(STR(?spaceRef)))) AS ?ri2)
1525
                  # 6. Load-number filter on bound ?np.
1526
                  GRAPH <%9$s> {
1527
                    ?np npa:hasLoadNumber ?ln .
1528
                    FILTER (?ln > %5$d)
1529
                  }
1530
                  # 7. Instantiation invalidation filter — outside the GRAPH block so the
1531
                  #    planner defers it until ?np is bound. Role-DECLARATION invalidation is
1532
                  #    deliberately NOT consulted: the tier already anchors on the admin-
1533
                  #    validated attachment (?ra), which is removed when an admin retracts it,
1534
                  #    so admin control is fully enforced there. Letting the declaration's
1535
                  #    author (usually not the space admin) supersede/retract their declaration
1536
                  #    strip a space's members is the same cross-author-strip anti-pattern as
1537
                  #    issue #112. Role IRIs are version-pinned, so the attached definition is
1538
                  #    immutable regardless of the declaration nanopub's later lifecycle.
1539
                  %6$s
1540
                  # 8. Dedup last — keyed on (ref, agent, nanopub).
1541
                  FILTER NOT EXISTS { GRAPH <%3$s> {
1542
                    ?existing a gen:RoleInstantiation ;
1543
                              npa:forSpaceRef ?spaceRef ;
1544
                              npa:forAgent ?agent ;
1545
                              npa:viaNanopub ?np .
1546
                  } }
1547
                }
1548
                """.formatted(
3✔
1549
                NPA.NAMESPACE,
1550
                GEN.NAMESPACE,
1551
                graph,
1552
                SpacesVocab.SPACES_GRAPH,
1553
                lastProcessed,
15✔
1554
                invalidationFilter("np"),
42✔
1555
                tierClass,
1556
                publisherConstraint,
1557
                NPA.GRAPH);
1558
    }
1559

1560
    /**
1561
     * Sub-space admit pass. Copies validated {@code npa:SubSpaceDeclaration}
1562
     * extraction rows into the space-state graph (preserving the {@code npasub:}
1563
     * subject) and emits convenience {@code <child> npa:isSubSpaceOf <parent>} and
1564
     * {@code <parent> npa:hasSubSpace <child>} direct triples. Two satisfaction
1565
     * modes joined by UNION:
1566
     * <ul>
1567
     *   <li>Mode A — the declaration's publisher is a validated admin of both the
1568
     *       child and the parent space.</li>
1569
     *   <li>Mode B — a different non-invalidated declaration for the same
1570
     *       {@code (child, parent)} pair exists, and the two publishers between
1571
     *       them cover both admin sides (i.e. one of them is admin of the child,
1572
     *       one of them is admin of the parent — possibly the same one twice if
1573
     *       both happen to be admin of both).</li>
1574
     * </ul>
1575
     *
1576
     * <p>Mode-B late-arrival: when only the partner declaration is new in this
1577
     * cycle (the primary is older than {@code lastProcessed}), the load-number
1578
     * filter on {@code ?np} excludes the candidate. The late-arrival sweep
1579
     * ({@link #runDownstreamWithoutLoadFilter}) re-runs this pass without the
1580
     * load filter and catches it.
1581
     */
1582
    static String subSpaceAdmitUpdate(IRI graph, long lastProcessed) {
1583
        return """
69✔
1584
                PREFIX npa: <%1$s>
1585
                PREFIX gen: <%2$s>
1586
                INSERT { GRAPH <%3$s> {
1587
                  ?d a npa:SubSpaceDeclaration ;
1588
                     npa:childSpace  ?child ;
1589
                     npa:parentSpace ?parent ;
1590
                     npa:viaNanopub  ?np .
1591
                  ?childRef  npa:isSubSpaceOf ?parentRef .
1592
                  ?parentRef npa:hasSubSpace  ?childRef  .
1593
                  # Reified per-(nanopub, ref-pair) provenance link (issue #125 finding #5):
1594
                  # carries npa:viaNanopub plus both the ref and IRI endpoints, so the
1595
                  # invalidation cleanup can drop the convenience edges below once no
1596
                  # surviving link backs them — instead of leaving them sticky until the
1597
                  # next periodic full rebuild.
1598
                  ?ssLink a npa:SubSpaceLink ;
1599
                          npa:viaNanopub     ?np ;
1600
                          npa:childSpaceRef  ?childRef ;
1601
                          npa:parentSpaceRef ?parentRef ;
1602
                          npa:childSpace     ?child ;
1603
                          npa:parentSpace    ?parent .
1604
                  # TRANSITIONAL-DUAL-EMIT (Phase 1.5; remove in Phase 4): IRI-valued
1605
                  # sub-space edge alongside the ref-to-ref one, so pre-ref published
1606
                  # queries that key on the bare Space IRI keep binding on a mixed-version
1607
                  # fleet. See doc/report-2026-06-12-mixed-fleet-spaceref-breakage.md.
1608
                  ?child  npa:isSubSpaceOf ?parent .
1609
                  ?parent npa:hasSubSpace  ?child  .
1610
                } }
1611
                WHERE {
1612
                  # 1. Anchor: candidate declarations from the extraction graph.
1613
                  GRAPH <%4$s> {
1614
                    ?d a npa:SubSpaceDeclaration ;
1615
                       npa:childSpace  ?child ;
1616
                       npa:parentSpace ?parent ;
1617
                       npa:pubkeyHash  ?pkh ;
1618
                       npa:viaNanopub  ?np .
1619
                  }
1620
                  # 2. Mirror: resolve ?pkh → ?publisher via the trust-approved row.
1621
                  GRAPH <%3$s> {
1622
                    ?acct a npa:AccountState ;
1623
                          npa:pubkey ?pkh ;
1624
                          npa:agent  ?publisher .
1625
                  }
1626
                  # 3. Authority gate, ref-keyed. The edge is emitted ref-to-ref between
1627
                  #    the child ref and parent ref the authorizing admin governs; the
1628
                  #    admin rows' dual-emitted npa:forSpace binds the refs to the child /
1629
                  #    parent IRIs (cross-product when an IRI has several governed refs).
1630
                  {
1631
                    # Mode A — publisher is admin of BOTH a child ref and a parent ref.
1632
                    GRAPH <%3$s> {
1633
                      ?riC a gen:RoleInstantiation ;
1634
                           npa:inverseProperty gen:hasAdmin ;
1635
                           npa:forSpace ?child ;
1636
                           npa:forSpaceRef ?childRef ;
1637
                           npa:forAgent ?publisher .
1638
                      ?riP a gen:RoleInstantiation ;
1639
                           npa:inverseProperty gen:hasAdmin ;
1640
                           npa:forSpace ?parent ;
1641
                           npa:forSpaceRef ?parentRef ;
1642
                           npa:forAgent ?publisher .
1643
                    }
1644
                  }
1645
                  UNION
1646
                  {
1647
                    # Mode B — co-declaration whose publisher covers the side this
1648
                    # one's publisher doesn't. Between {publisher, publisher2},
1649
                    # both admin sides must be covered.
1650
                    GRAPH <%4$s> {
1651
                      ?d2 a npa:SubSpaceDeclaration ;
1652
                          npa:childSpace  ?child ;
1653
                          npa:parentSpace ?parent ;
1654
                          npa:pubkeyHash  ?pkh2 ;
1655
                          npa:viaNanopub  ?np2 .
1656
                      FILTER (?np2 != ?np)
1657
                    }
1658
                    %8$s
1659
                    GRAPH <%3$s> {
1660
                      ?acct2 a npa:AccountState ;
1661
                             npa:pubkey ?pkh2 ;
1662
                             npa:agent  ?publisher2 .
1663
                      ?riA a gen:RoleInstantiation ;
1664
                           npa:inverseProperty gen:hasAdmin ;
1665
                           npa:forSpace ?child ;
1666
                           npa:forSpaceRef ?childRef .
1667
                      { ?riA npa:forAgent ?publisher } UNION { ?riA npa:forAgent ?publisher2 }
1668
                      ?riB a gen:RoleInstantiation ;
1669
                           npa:inverseProperty gen:hasAdmin ;
1670
                           npa:forSpace ?parent ;
1671
                           npa:forSpaceRef ?parentRef .
1672
                      { ?riB npa:forAgent ?publisher } UNION { ?riB npa:forAgent ?publisher2 }
1673
                    }
1674
                  }
1675
                  # 4. Invalidation filter on the primary declaration's nanopub.
1676
                  %6$s
1677
                  # 5. Load-number filter on bound ?np.
1678
                  GRAPH <%7$s> {
1679
                    ?np npa:hasLoadNumber ?ln .
1680
                    FILTER (?ln > %5$d)
1681
                  }
1682
                  # 6. Mint the per-(nanopub, ref-pair) provenance link IRI and dedup on it
1683
                  #    (not on the bare edge). Keyed on ?np so every backing declaration of
1684
                  #    the same ref-pair records its own removable link; the convenience
1685
                  #    edges above are re-asserted idempotently.
1686
                  BIND(IRI(CONCAT("http://purl.org/nanopub/admin/spacelink/subspace/",
1687
                                  MD5(CONCAT(STR(?np), "|", STR(?childRef), "|", STR(?parentRef))))) AS ?ssLink)
1688
                  FILTER NOT EXISTS { GRAPH <%3$s> {
1689
                    ?ssLink a npa:SubSpaceLink .
1690
                  } }
1691
                }
1692
                """.formatted(
3✔
1693
                NPA.NAMESPACE,
1694
                GEN.NAMESPACE,
1695
                graph,
1696
                SpacesVocab.SPACES_GRAPH,
1697
                lastProcessed,
15✔
1698
                invalidationFilter("np"),
27✔
1699
                NPA.GRAPH,
1700
                invalidationFilter("np2"));
6✔
1701
    }
1702

1703
    /**
1704
     * Maintained-resource admit pass. Copies validated
1705
     * {@code npa:MaintainedResourceDeclaration} extraction rows into the space-state
1706
     * graph (preserving the {@code npamrd:} subject) and emits convenience
1707
     * {@code <r> npa:isMaintainedBy <s>} and {@code <s> npa:hasMaintainedResource <r>}
1708
     * direct triples. Single satisfaction mode:
1709
     * <ul>
1710
     *   <li>Mode A — the declaration's publisher is a validated admin of the
1711
     *       maintaining space.</li>
1712
     * </ul>
1713
     *
1714
     * <p>No Mode B because only one space is involved; the two-sides-must-be-covered
1715
     * concern that drives sub-space Mode B doesn't apply. Late-arrival is still
1716
     * possible (declaration lands before the publisher's admin grant becomes valid):
1717
     * the load-number filter on {@code ?np} excludes the candidate, and the
1718
     * late-arrival sweep ({@link #runDownstreamWithoutLoadFilter}) re-runs this pass
1719
     * without the load filter and catches it.
1720
     */
1721
    static String maintainedResourceAdmitUpdate(IRI graph, long lastProcessed) {
1722
        return """
69✔
1723
                PREFIX npa: <%1$s>
1724
                PREFIX gen: <%2$s>
1725
                INSERT { GRAPH <%3$s> {
1726
                  ?d a npa:MaintainedResourceDeclaration ;
1727
                     npa:resourceIri     ?r ;
1728
                     npa:maintainerSpace ?s ;
1729
                     npa:viaNanopub      ?np .
1730
                  ?r npa:isMaintainedBy        ?sRef .
1731
                  ?sRef npa:hasMaintainedResource ?r .
1732
                  # Uniform ref-valued resource→governing-space-ref edge (issue #130). The
1733
                  # same predicate the reflexive space self-edge uses, so a single consumer
1734
                  # hop covers both "resource maintained by space S" and "resource IS a space".
1735
                  # Backed by the same MaintainedResourceLink below, so the invalidation
1736
                  # cleanup sweeps it alongside isMaintainedBy.
1737
                  ?r npa:hasGoverningSpaceRef  ?sRef .
1738
                  # Reified per-(nanopub, resource→ref) provenance link (issue #125 finding
1739
                  # #5): lets the invalidation cleanup drop the convenience edges below once
1740
                  # no surviving link backs them, instead of leaving them sticky.
1741
                  ?mrLink a npa:MaintainedResourceLink ;
1742
                          npa:viaNanopub         ?np ;
1743
                          npa:resourceIri        ?r ;
1744
                          npa:maintainerSpaceRef ?sRef ;
1745
                          npa:maintainerSpace    ?s .
1746
                  # TRANSITIONAL-DUAL-EMIT (Phase 1.5; remove in Phase 4): IRI-valued
1747
                  # maintained-resource edge alongside the resource→ref one, so pre-ref
1748
                  # published queries (e.g. get-view-displays' maintained hop) keep binding
1749
                  # on a mixed-version fleet. This is the edge whose absence broke 1.15.0 —
1750
                  # see doc/report-2026-06-12-mixed-fleet-spaceref-breakage.md.
1751
                  ?r npa:isMaintainedBy        ?s .
1752
                  ?s npa:hasMaintainedResource ?r .
1753
                } }
1754
                WHERE {
1755
                  # 1. Anchor: candidate declarations from the extraction graph.
1756
                  GRAPH <%4$s> {
1757
                    ?d a npa:MaintainedResourceDeclaration ;
1758
                       npa:resourceIri     ?r ;
1759
                       npa:maintainerSpace ?s ;
1760
                       npa:pubkeyHash      ?pkh ;
1761
                       npa:viaNanopub      ?np .
1762
                  }
1763
                  # 2. Mirror: resolve ?pkh → ?publisher via the trust-approved row.
1764
                  GRAPH <%3$s> {
1765
                    ?acct a npa:AccountState ;
1766
                          npa:pubkey ?pkh ;
1767
                          npa:agent  ?publisher .
1768
                    # 3. Authority gate (Mode A only): publisher is admin of a ref of the
1769
                    #    maintaining space. ?sRef = that ref (resource → ref edge).
1770
                    ?riA a gen:RoleInstantiation ;
1771
                         npa:inverseProperty gen:hasAdmin ;
1772
                         npa:forSpace ?s ;
1773
                         npa:forSpaceRef ?sRef ;
1774
                         npa:forAgent ?publisher .
1775
                  }
1776
                  # 4. Invalidation filter on the declaration's nanopub.
1777
                  %6$s
1778
                  # 5. Load-number filter on bound ?np.
1779
                  GRAPH <%7$s> {
1780
                    ?np npa:hasLoadNumber ?ln .
1781
                    FILTER (?ln > %5$d)
1782
                  }
1783
                  # 6. Mint the per-(nanopub, resource→ref) provenance link IRI and dedup on
1784
                  #    it (not on the bare edge), so every backing declaration records its own
1785
                  #    removable link; the convenience edges above are re-asserted idempotently.
1786
                  BIND(IRI(CONCAT("http://purl.org/nanopub/admin/spacelink/maintained/",
1787
                                  MD5(CONCAT(STR(?np), "|", STR(?r), "|", STR(?sRef))))) AS ?mrLink)
1788
                  FILTER NOT EXISTS { GRAPH <%3$s> {
1789
                    ?mrLink a npa:MaintainedResourceLink .
1790
                  } }
1791
                }
1792
                """.formatted(
3✔
1793
                NPA.NAMESPACE,
1794
                GEN.NAMESPACE,
1795
                graph,
1796
                SpacesVocab.SPACES_GRAPH,
1797
                lastProcessed,
15✔
1798
                invalidationFilter("np"),
18✔
1799
                NPA.GRAPH);
1800
    }
1801

1802
    /**
1803
     * Space-alias admit pass (issue #113). Copies validated
1804
     * {@code npa:SpaceAliasDeclaration} extraction rows into the space-state graph
1805
     * (preserving the {@code npaalias:} subject) and emits the directional
1806
     * {@code <alias> npa:sameAsSpace <canonical>} edge consumed by the alias-aware
1807
     * admin-authority lookups in {@link #attachmentValidationUpdate},
1808
     * {@link #PUBLISHER_IS_ADMIN}, and {@link #publisherIsTieredRole}.
1809
     *
1810
     * <p>Two gates, both read against the (already-settled) admin closure in the
1811
     * space-state graph:
1812
     * <ul>
1813
     *   <li><b>Authority</b> — the declaration's publisher (resolved via the mirrored
1814
     *       trust-approved {@code AccountState}) is a validated admin of the
1815
     *       <em>canonical</em> space. The alias is declared inside the canonical
1816
     *       space's own {@code gen:Space} nanopub, so this is the same evidence rule
1817
     *       as a {@code gen:hasRole} attachment.</li>
1818
     *   <li><b>Anti-hijack</b> — the alias must not be an independently-governed live
1819
     *       space: it must have no admin who is not also an admin of the canonical
1820
     *       space ({@code admins(alias) ⊆ admins(canonical)}). The common rename case
1821
     *       (the alias's own definition was superseded, so it has no live admin
1822
     *       closure) passes trivially; an attacker publishing
1823
     *       {@code <evil> owl:sameAs <activeSpace>} is rejected because the active
1824
     *       space has admins not in evil's set.</li>
1825
     * </ul>
1826
     *
1827
     * <p>Late-arrival: when the canonical admin grant only becomes valid in the same
1828
     * cycle as the declaration, the load-number filter on {@code ?np} excludes the
1829
     * candidate; the late-arrival sweep ({@link #runDownstreamWithoutLoadFilter})
1830
     * re-runs this pass without the load filter and catches it.
1831
     */
1832
    static String aliasAdmitUpdate(IRI graph, long lastProcessed) {
1833
        // Ref-keyed (see doc/design-spaceref-isolation.md). The declaration names bare
1834
        // canonical/alias IRIs. It is admitted per canonical *ref* whose admin set
1835
        // contains the publisher; the emitted edge is ref-valued on the canonical side
1836
        // (<alias> npa:sameAsSpace <canonicalRef>), which is what the alias-aware admin
1837
        // lookups in the attachment tier consume. Anti-hijack compares the alias IRI's
1838
        // admins against that specific canonical ref's admins — strictly tighter than the
1839
        // old bare-IRI form.
1840
        return """
69✔
1841
                PREFIX npa: <%1$s>
1842
                PREFIX gen: <%2$s>
1843
                INSERT { GRAPH <%3$s> {
1844
                  ?d a npa:SpaceAliasDeclaration ;
1845
                     npa:canonicalSpace ?canonical ;
1846
                     npa:aliasSpace     ?alias ;
1847
                     npa:viaNanopub     ?np .
1848
                  ?alias npa:sameAsSpace ?canonRef .
1849
                  # Reified per-(nanopub, alias→canonical ref) provenance link (issue #125
1850
                  # finding #5). The alias edge feeds the admin-authority closure, so this
1851
                  # is the load-bearing case: the cleanup can now drop the edge when its
1852
                  # declaration is invalidated, rather than letting admin authority outlive
1853
                  # a retraction until the next periodic full rebuild.
1854
                  ?alLink a npa:SpaceAliasLink ;
1855
                          npa:viaNanopub        ?np ;
1856
                          npa:aliasSpace        ?alias ;
1857
                          npa:canonicalSpaceRef ?canonRef ;
1858
                          npa:canonicalSpace    ?canonical .
1859
                  # TRANSITIONAL-DUAL-EMIT (Phase 1.5; remove in Phase 4): IRI-valued
1860
                  # alias edge alongside the ref-valued one, so pre-ref published queries
1861
                  # that resolve owl:sameAs by bare canonical IRI keep binding on a
1862
                  # mixed-version fleet. Internal alias-aware lookups (attachment tier)
1863
                  # join through npa:forSpaceRef, which is ref-valued, so this IRI-valued
1864
                  # object never satisfies them — it is inert internally, read-only for
1865
                  # legacy consumers. See doc/report-2026-06-12-mixed-fleet-spaceref-breakage.md.
1866
                  ?alias npa:sameAsSpace ?canonical .
1867
                } }
1868
                WHERE {
1869
                  # 1. Anchor: candidate alias declarations from the extraction graph.
1870
                  GRAPH <%4$s> {
1871
                    ?d a npa:SpaceAliasDeclaration ;
1872
                       npa:canonicalSpace ?canonical ;
1873
                       npa:aliasSpace     ?alias ;
1874
                       npa:pubkeyHash     ?pkh ;
1875
                       npa:viaNanopub     ?np .
1876
                  }
1877
                  # 2. Authority gate per canonical ref: ?canonRef is a ref of ?canonical
1878
                  #    whose admin set contains the declaration's publisher.
1879
                  GRAPH <%4$s> { ?canonRef npa:spaceIri ?canonical . }
1880
                  GRAPH <%3$s> {
1881
                    ?acct a npa:AccountState ;
1882
                          npa:pubkey ?pkh ;
1883
                          npa:agent  ?publisher .
1884
                    ?adminRI a gen:RoleInstantiation ;
1885
                             npa:inverseProperty gen:hasAdmin ;
1886
                             npa:forSpaceRef ?canonRef ;
1887
                             npa:forAgent ?publisher .
1888
                  }
1889
                  # 3. Anti-hijack: the alias IRI must have no admin who is not also an
1890
                  #    admin of this canonical ref (admins(alias) ⊆ admins(canonRef)).
1891
                  FILTER NOT EXISTS {
1892
                    GRAPH <%3$s> {
1893
                      ?aliasAdmin a gen:RoleInstantiation ;
1894
                                  npa:inverseProperty gen:hasAdmin ;
1895
                                  npa:forSpace ?alias ;
1896
                                  npa:forAgent ?otherAgent .
1897
                    }
1898
                    FILTER NOT EXISTS {
1899
                      GRAPH <%3$s> {
1900
                        ?canonAdmin a gen:RoleInstantiation ;
1901
                                    npa:inverseProperty gen:hasAdmin ;
1902
                                    npa:forSpaceRef ?canonRef ;
1903
                                    npa:forAgent ?otherAgent .
1904
                      }
1905
                    }
1906
                  }
1907
                  # 4. Invalidation filter on the declaration's nanopub.
1908
                  %6$s
1909
                  # 5. Load-number filter on bound ?np.
1910
                  GRAPH <%7$s> {
1911
                    ?np npa:hasLoadNumber ?ln .
1912
                    FILTER (?ln > %5$d)
1913
                  }
1914
                  # 6. Mint the per-(nanopub, alias→canonical ref) provenance link IRI and
1915
                  #    dedup on it (not on the bare edge), so every backing declaration records
1916
                  #    its own removable link; the convenience edges above are re-asserted
1917
                  #    idempotently.
1918
                  BIND(IRI(CONCAT("http://purl.org/nanopub/admin/spacelink/alias/",
1919
                                  MD5(CONCAT(STR(?np), "|", STR(?alias), "|", STR(?canonRef))))) AS ?alLink)
1920
                  FILTER NOT EXISTS { GRAPH <%3$s> {
1921
                    ?alLink a npa:SpaceAliasLink .
1922
                  } }
1923
                }
1924
                """.formatted(
3✔
1925
                NPA.NAMESPACE,
1926
                GEN.NAMESPACE,
1927
                graph,
1928
                SpacesVocab.SPACES_GRAPH,
1929
                lastProcessed,
15✔
1930
                invalidationFilter("np"),
18✔
1931
                NPA.GRAPH);
1932
    }
1933

1934
    /**
1935
     * URL-prefix sub-space fallback admit pass. For every pair of {@code SpaceRef}
1936
     * aggregates where the child's {@code npa:hasIdPrefix} matches the parent's
1937
     * {@code npa:spaceIri}, emits convenience {@code <child> npa:isSubSpaceOf <parent>}
1938
     * and {@code <parent> npa:hasSubSpace <child>} direct triples plus a reified
1939
     * {@code npa:DerivedSubSpaceLink} tag carrying {@code npa:derivationKind
1940
     * npa:byUrlPrefix} so consumers can hide derived edges.
1941
     *
1942
     * <p>Per-child suppression: any validated {@code npa:SubSpaceDeclaration} on the
1943
     * child in {@code npass:<…>} suppresses every fallback edge for that child.
1944
     * Suppression checks the validated set (not raw extraction-graph declarations)
1945
     * so an unapproved or in-flight Mode B declaration doesn't silently hide both
1946
     * the URL-prefix fallback and the (still-invalid) explicit relation.
1947
     *
1948
     * <p>Run order: must run after {@link #subSpaceAdmitUpdate} commits in the
1949
     * same cycle so the suppression check sees this cycle's freshly-validated
1950
     * declarations.
1951
     *
1952
     * <p>No load-number filter: the fallback depends on which Spaces exist (parent
1953
     * + child {@code SpaceRef}s), not on which were just added. Always full-scan;
1954
     * the dedup {@code FILTER NOT EXISTS} on the tag IRI prevents re-insertion.
1955
     *
1956
     * <p>No invalidation handling: derived edges have no source nanopub. Two
1957
     * staleness modes: (a) child later gets first validated declaration → old
1958
     * derived edges stay sticky until the next periodic rebuild (same policy as
1959
     * admin-RI invalidation); (b) child loses last validated declaration → the
1960
     * regular fallback pass on the next cycle re-engages, adds derived edges
1961
     * incrementally, no rebuild needed.
1962
     */
1963
    static String subSpacePrefixFallbackUpdate(IRI graph) {
1964
        return """
48✔
1965
                PREFIX npa: <%1$s>
1966
                INSERT { GRAPH <%2$s> {
1967
                  ?childRef  npa:isSubSpaceOf ?parentRef .
1968
                  ?parentRef npa:hasSubSpace  ?childRef  .
1969
                  # TRANSITIONAL-DUAL-EMIT (Phase 1.5; remove in Phase 4): IRI-valued
1970
                  # derived sub-space edge alongside the ref-to-ref one, mirroring the
1971
                  # explicit sub-space pass, so pre-ref published queries keep binding on a
1972
                  # mixed-version fleet. See doc/report-2026-06-12-mixed-fleet-spaceref-breakage.md.
1973
                  ?child  npa:isSubSpaceOf ?parent .
1974
                  ?parent npa:hasSubSpace  ?child  .
1975
                  ?tagIri a npa:DerivedSubSpaceLink ;
1976
                          npa:childSpace     ?child ;
1977
                          npa:parentSpace    ?parent ;
1978
                          # Ref endpoints too (issue #125 finding #5), so the sub-space
1979
                          # orphan-sweep recognizes a prefix-derived ref edge as backed and
1980
                          # never deletes it. Derived links have no source nanopub, so they
1981
                          # are never invalidation-deleted; the fallback self-heals each cycle.
1982
                          npa:childSpaceRef  ?childRef ;
1983
                          npa:parentSpaceRef ?parentRef ;
1984
                          npa:derivationKind npa:byUrlPrefix .
1985
                } }
1986
                WHERE {
1987
                  # 1. Anchor: child SpaceRef → its path-prefixes (extracted at load
1988
                  #    time from the Space IRI; see SpacesExtractor.enumerateIdPrefixes).
1989
                  GRAPH <%3$s> {
1990
                    ?childRef  npa:spaceIri    ?child ;
1991
                               npa:hasIdPrefix ?parent .
1992
                    # 2. Parent SpaceRef must exist for the same IRI as the prefix.
1993
                    ?parentRef npa:spaceIri    ?parent .
1994
                  }
1995
                  # 3. Suppress fallback for any child that has a validated declaration
1996
                  #    in this state graph. Per-child IRI, all-or-nothing.
1997
                  FILTER NOT EXISTS {
1998
                    GRAPH <%2$s> {
1999
                      ?d a npa:SubSpaceDeclaration ;
2000
                         npa:childSpace ?child .
2001
                    }
2002
                  }
2003
                  # 4. Mint a deterministic tag IRI per (child ref, parent ref) — the edge
2004
                  #    is emitted ref-to-ref, so the tag and dedup are per ref-pair.
2005
                  BIND(IRI(CONCAT("http://purl.org/nanopub/admin/derivedlink/",
2006
                                  MD5(CONCAT(STR(?childRef), "|", STR(?parentRef))))) AS ?tagIri)
2007
                  # 5. Dedup: don't re-insert if this tag is already present.
2008
                  FILTER NOT EXISTS {
2009
                    GRAPH <%2$s> {
2010
                      ?tagIri a npa:DerivedSubSpaceLink .
2011
                    }
2012
                  }
2013
                }
2014
                """.formatted(
3✔
2015
                NPA.NAMESPACE,
2016
                graph,
2017
                SpacesVocab.SPACES_GRAPH);
2018
    }
2019

2020
    /**
2021
     * Reflexive governing-space-ref pass (issue #130). For every {@code SpaceRef}
2022
     * aggregate {@code ?spaceRef} (identified by {@code npa:spaceIri ?space} in the
2023
     * extraction graph), emits {@code <space> npa:hasGoverningSpaceRef <spaceRef>} into
2024
     * the space-state graph — the space pointing at its own ref through the same predicate
2025
     * a maintained resource uses to point at its maintaining space's ref (emitted in
2026
     * {@link #maintainedResourceAdmitUpdate}).
2027
     *
2028
     * <p>This removes the zero-hop special case from consumer authority gates: instead of
2029
     * {@code ?resource npa:isMaintainedBy? ?space} (a bare-IRI optional path that breaks
2030
     * once the hop is ref-valued), a consumer does a single mandatory
2031
     * {@code ?resource npa:hasGoverningSpaceRef ?spaceRef} that binds whether the resource
2032
     * is a maintained resource or a space itself. A space IRI claimed by several refs emits
2033
     * one edge per ref — the non-ref consumer variant's merged-across-refs behaviour falls
2034
     * out naturally; the ref variant pins {@code ?passedRef}.
2035
     *
2036
     * <p>Self-healing, like {@link #subSpacePrefixFallbackUpdate}: the edge has no source
2037
     * nanopub (it follows purely from a {@code SpaceRef} existing), so there is no
2038
     * invalidation handling and no load-number filter — always full-scan, with the dedup
2039
     * {@code FILTER NOT EXISTS} on the edge preventing re-insertion. A {@code SpaceRef}
2040
     * disappearing is itself a structural-rebuild event, which clears its reflexive edge.
2041
     */
2042
    static String governingSpaceRefReflexiveUpdate(IRI graph) {
2043
        return """
48✔
2044
                PREFIX npa: <%1$s>
2045
                INSERT { GRAPH <%2$s> {
2046
                  ?space npa:hasGoverningSpaceRef ?spaceRef .
2047
                } }
2048
                WHERE {
2049
                  GRAPH <%3$s> { ?spaceRef npa:spaceIri ?space . }
2050
                  FILTER NOT EXISTS { GRAPH <%2$s> {
2051
                    ?space npa:hasGoverningSpaceRef ?spaceRef .
2052
                  } }
2053
                }
2054
                """.formatted(
3✔
2055
                NPA.NAMESPACE,
2056
                graph,
2057
                SpacesVocab.SPACES_GRAPH);
2058
    }
2059

2060
    // ---------------- Invalidation templates (incremental cycle) ----------------
2061

2062
    /**
2063
     * WHERE clause shared by the admin-RI invalidation ASK precheck and the
2064
     * matching DELETE. Identifies admin-tier {@code gen:RoleInstantiation} rows
2065
     * in the space-state graph whose {@code npa:viaNanopub} is the target of an
2066
     * {@code npx:invalidates} triple in {@code npa:graph} whose subject nanopub
2067
     * has a load number in {@code (lastProcessed, ∞)}.
2068
     */
2069
    static String adminInvalidationCheckWhere(IRI graph, long lastProcessed) {
2070
        return String.format("""
60✔
2071
                  GRAPH <%1$s> {
2072
                    ?ri a gen:RoleInstantiation ;
2073
                        npa:inverseProperty gen:hasAdmin ;
2074
                        npa:viaNanopub ?np .
2075
                  }
2076
                  GRAPH <%2$s> {
2077
                    ?invNp <%3$s> ?np ;
2078
                           npa:hasLoadNumber ?ln .
2079
                    FILTER (?ln > %4$d)
2080
                    %5$s
2081
                  }
2082
                """, graph, NPA.GRAPH, NPX.INVALIDATES, lastProcessed,
18✔
2083
                samePublisherClause("invNp", "np"));
6✔
2084
    }
2085

2086
    /** DELETE template for admin-tier RoleInstantiations whose source nanopub was invalidated. */
2087
    static String adminInvalidationDelete(IRI graph, long lastProcessed) {
2088
        return String.format("""
63✔
2089
                PREFIX npa: <%1$s>
2090
                PREFIX gen: <%2$s>
2091
                DELETE { GRAPH <%3$s> {
2092
                  ?ri ?p ?o .
2093
                } }
2094
                WHERE {
2095
                  GRAPH <%3$s> { ?ri ?p ?o . }
2096
                %4$s
2097
                }
2098
                """, NPA.NAMESPACE, GEN.NAMESPACE, graph,
2099
                adminInvalidationCheckWhere(graph, lastProcessed));
6✔
2100
    }
2101

2102
    /** WHERE clause for RoleAssignment invalidation. */
2103
    static String roleAssignmentInvalidationCheckWhere(IRI graph, long lastProcessed) {
2104
        return String.format("""
60✔
2105
                  GRAPH <%1$s> {
2106
                    ?ra a gen:RoleAssignment ;
2107
                        npa:viaNanopub ?np .
2108
                  }
2109
                  GRAPH <%2$s> {
2110
                    ?invNp <%3$s> ?np ;
2111
                           npa:hasLoadNumber ?ln .
2112
                    FILTER (?ln > %4$d)
2113
                    %5$s
2114
                  }
2115
                """, graph, NPA.GRAPH, NPX.INVALIDATES, lastProcessed,
18✔
2116
                samePublisherClause("invNp", "np"));
6✔
2117
    }
2118

2119
    /** DELETE template for RoleAssignments whose source nanopub was invalidated. */
2120
    static String roleAssignmentInvalidationDelete(IRI graph, long lastProcessed) {
2121
        return String.format("""
63✔
2122
                PREFIX npa: <%1$s>
2123
                PREFIX gen: <%2$s>
2124
                DELETE { GRAPH <%3$s> {
2125
                  ?ra ?p ?o .
2126
                } }
2127
                WHERE {
2128
                  GRAPH <%3$s> { ?ra ?p ?o . }
2129
                %4$s
2130
                }
2131
                """, NPA.NAMESPACE, GEN.NAMESPACE, graph,
2132
                roleAssignmentInvalidationCheckWhere(graph, lastProcessed));
6✔
2133
    }
2134

2135
    /**
2136
     * DELETE template for non-admin (leaf-tier) RoleInstantiations whose source
2137
     * nanopub was invalidated. Identified as {@code gen:RoleInstantiation} rows
2138
     * lacking the admin-pinning {@code npa:inverseProperty gen:hasAdmin} triple.
2139
     * No flag is set; leaf-tier removals are recoverable on the next cycle.
2140
     */
2141
    static String leafTierInvalidationDelete(IRI graph, long lastProcessed) {
2142
        return String.format("""
84✔
2143
                PREFIX npa: <%1$s>
2144
                PREFIX gen: <%2$s>
2145
                DELETE { GRAPH <%3$s> {
2146
                  ?ri ?p ?o .
2147
                } }
2148
                WHERE {
2149
                  GRAPH <%3$s> {
2150
                    ?ri a gen:RoleInstantiation ;
2151
                        npa:viaNanopub ?np .
2152
                    FILTER NOT EXISTS { ?ri npa:inverseProperty gen:hasAdmin }
2153
                    ?ri ?p ?o .
2154
                  }
2155
                  GRAPH <%4$s> {
2156
                    ?invNp <%5$s> ?np ;
2157
                           npa:hasLoadNumber ?ln .
2158
                    FILTER (?ln > %6$d)
2159
                    %7$s
2160
                  }
2161
                }
2162
                """, NPA.NAMESPACE, GEN.NAMESPACE, graph,
2163
                NPA.GRAPH, NPX.INVALIDATES, lastProcessed,
18✔
2164
                samePublisherClause("invNp", "np"));
6✔
2165
    }
2166

2167
    /**
2168
     * WHERE clause shared by the sub-space invalidation ASK precheck and the
2169
     * matching DELETE. Identifies validated {@code npa:SubSpaceDeclaration} rows
2170
     * in the space-state graph whose {@code npa:viaNanopub} is the target of an
2171
     * {@code npx:invalidates} triple in {@code npa:graph} whose subject nanopub
2172
     * has a load number in {@code (lastProcessed, ∞)}.
2173
     */
2174
    static String subSpaceInvalidationCheckWhere(IRI graph, long lastProcessed) {
2175
        return String.format("""
60✔
2176
                  GRAPH <%1$s> {
2177
                    ?d a npa:SubSpaceDeclaration ;
2178
                       npa:viaNanopub ?np .
2179
                  }
2180
                  GRAPH <%2$s> {
2181
                    ?invNp <%3$s> ?np ;
2182
                           npa:hasLoadNumber ?ln .
2183
                    FILTER (?ln > %4$d)
2184
                    %5$s
2185
                  }
2186
                """, graph, NPA.GRAPH, NPX.INVALIDATES, lastProcessed,
18✔
2187
                samePublisherClause("invNp", "np"));
6✔
2188
    }
2189

2190
    /**
2191
     * DELETE template for validated {@code npa:SubSpaceDeclaration} rows whose
2192
     * source nanopub was invalidated. Removes the per-declaration row by subject;
2193
     * the convenience direct triples ({@code <child> npa:isSubSpaceOf <parent>}
2194
     * and inverse) are then dropped by {@link #subSpaceConvenienceEdgeCleanup} in the
2195
     * same cycle (issue #125 finding #5) once no surviving link backs them.
2196
     */
2197
    static String subSpaceInvalidationDelete(IRI graph, long lastProcessed) {
2198
        return String.format("""
63✔
2199
                PREFIX npa: <%1$s>
2200
                PREFIX gen: <%2$s>
2201
                DELETE { GRAPH <%3$s> {
2202
                  ?d ?p ?o .
2203
                } }
2204
                WHERE {
2205
                  GRAPH <%3$s> { ?d ?p ?o . }
2206
                %4$s
2207
                }
2208
                """, NPA.NAMESPACE, GEN.NAMESPACE, graph,
2209
                subSpaceInvalidationCheckWhere(graph, lastProcessed));
6✔
2210
    }
2211

2212
    /**
2213
     * DELETE template for validated {@code npa:MaintainedResourceDeclaration} rows
2214
     * whose source nanopub was invalidated. Removes the per-declaration row by
2215
     * subject; the convenience direct triples ({@code <r> npa:isMaintainedBy <s>}
2216
     * and inverse) are then dropped by {@link #maintainedResourceConvenienceEdgeCleanup}
2217
     * in the same cycle (issue #125 finding #5). No structural-rebuild flag —
2218
     * maintained-resource is a leaf relation, no downstream consumers depend on its
2219
     * closure, so the prompt edge cleanup fully resolves its invalidation.
2220
     */
2221
    static String maintainedResourceInvalidationDelete(IRI graph, long lastProcessed) {
2222
        return String.format("""
84✔
2223
                PREFIX npa: <%1$s>
2224
                PREFIX gen: <%2$s>
2225
                DELETE { GRAPH <%3$s> {
2226
                  ?d ?p ?o .
2227
                } }
2228
                WHERE {
2229
                  GRAPH <%3$s> {
2230
                    ?d a npa:MaintainedResourceDeclaration ;
2231
                       npa:viaNanopub ?np .
2232
                    ?d ?p ?o .
2233
                  }
2234
                  GRAPH <%4$s> {
2235
                    ?invNp <%5$s> ?np ;
2236
                           npa:hasLoadNumber ?ln .
2237
                    FILTER (?ln > %6$d)
2238
                    %7$s
2239
                  }
2240
                }
2241
                """, NPA.NAMESPACE, GEN.NAMESPACE, graph,
2242
                NPA.GRAPH, NPX.INVALIDATES, lastProcessed,
18✔
2243
                samePublisherClause("invNp", "np"));
6✔
2244
    }
2245

2246
    /**
2247
     * WHERE clause shared by the alias invalidation ASK precheck and the matching
2248
     * DELETE. Identifies validated {@code npa:SpaceAliasDeclaration} rows in the
2249
     * space-state graph whose {@code npa:viaNanopub} is the target of an
2250
     * {@code npx:invalidates} triple in {@code npa:graph} whose subject nanopub has a
2251
     * load number in {@code (lastProcessed, ∞)}.
2252
     */
2253
    static String aliasInvalidationCheckWhere(IRI graph, long lastProcessed) {
2254
        return String.format("""
60✔
2255
                  GRAPH <%1$s> {
2256
                    ?d a npa:SpaceAliasDeclaration ;
2257
                       npa:viaNanopub ?np .
2258
                  }
2259
                  GRAPH <%2$s> {
2260
                    ?invNp <%3$s> ?np ;
2261
                           npa:hasLoadNumber ?ln .
2262
                    FILTER (?ln > %4$d)
2263
                    %5$s
2264
                  }
2265
                """, graph, NPA.GRAPH, NPX.INVALIDATES, lastProcessed,
18✔
2266
                samePublisherClause("invNp", "np"));
6✔
2267
    }
2268

2269
    /**
2270
     * DELETE template for validated {@code npa:SpaceAliasDeclaration} rows whose
2271
     * source nanopub was invalidated. Removes the per-declaration row by subject; the
2272
     * convenience {@code <alias> npa:sameAsSpace <canonical>} edge is then dropped by
2273
     * {@link #aliasConvenienceEdgeCleanup} in the same cycle (issue #125 finding #5),
2274
     * so an alias can no longer grant admin authority after its declaration is retracted.
2275
     * The alias feeds the authority closure, so this kind is still structural and flips
2276
     * {@code npa:needsFullRebuild} to bound any rows already derived through the edge.
2277
     */
2278
    static String aliasInvalidationDelete(IRI graph, long lastProcessed) {
2279
        return String.format("""
63✔
2280
                PREFIX npa: <%1$s>
2281
                PREFIX gen: <%2$s>
2282
                DELETE { GRAPH <%3$s> {
2283
                  ?d ?p ?o .
2284
                } }
2285
                WHERE {
2286
                  GRAPH <%3$s> { ?d ?p ?o . }
2287
                %4$s
2288
                }
2289
                """, NPA.NAMESPACE, GEN.NAMESPACE, graph,
2290
                aliasInvalidationCheckWhere(graph, lastProcessed));
6✔
2291
    }
2292

2293
    /**
2294
     * WHERE clause shared by the maintained-resource invalidation ASK precheck and the
2295
     * matching cleanup. Identifies validated {@code npa:MaintainedResourceDeclaration}
2296
     * rows in the space-state graph whose {@code npa:viaNanopub} is the target of an
2297
     * {@code npx:invalidates} triple in {@code npa:graph} whose subject nanopub has a
2298
     * load number in {@code (lastProcessed, ∞)}.
2299
     */
2300
    static String maintainedResourceInvalidationCheckWhere(IRI graph, long lastProcessed) {
2301
        return String.format("""
×
2302
                  GRAPH <%1$s> {
2303
                    ?d a npa:MaintainedResourceDeclaration ;
2304
                       npa:viaNanopub ?np .
2305
                  }
2306
                  GRAPH <%2$s> {
2307
                    ?invNp <%3$s> ?np ;
2308
                           npa:hasLoadNumber ?ln .
2309
                    FILTER (?ln > %4$d)
2310
                    %5$s
2311
                  }
2312
                """, graph, NPA.GRAPH, NPX.INVALIDATES, lastProcessed,
×
2313
                samePublisherClause("invNp", "np"));
×
2314
    }
2315

2316
    /**
2317
     * Convenience-edge cleanup for invalidated sub-space declarations (issue #125
2318
     * finding #5). Run after {@link #subSpaceInvalidationDelete} (which removes the
2319
     * {@code npa:SubSpaceDeclaration} rows). Two phases as one multi-operation update:
2320
     * <ol>
2321
     *   <li>delete every {@code npa:SubSpaceLink} provenance link whose
2322
     *       {@code npa:viaNanopub} was invalidated (same {@code npx:invalidates} +
2323
     *       same-publisher gate as the declaration delete);</li>
2324
     *   <li>orphan-sweep: delete the convenience {@code npa:isSubSpaceOf} /
2325
     *       {@code npa:hasSubSpace} edges (both ref- and IRI-valued) that no surviving
2326
     *       link backs — neither a {@code npa:SubSpaceLink} (explicit declaration) nor a
2327
     *       {@code npa:DerivedSubSpaceLink} (URL-prefix fallback).</li>
2328
     * </ol>
2329
     * Edges backed by another surviving declaration or by the URL-prefix fallback are
2330
     * kept. The {@code npa:needsFullRebuild} flag still fires for the structural kind, so
2331
     * downstream rows derived through a removed edge remain rebuild-bounded; this only
2332
     * stops the convenience edges themselves from going sticky.
2333
     */
2334
    static String subSpaceConvenienceEdgeCleanup(IRI graph, long lastProcessed) {
2335
        return String.format("""
72✔
2336
                PREFIX npa: <%1$s>
2337
                # 1. Drop sub-space provenance links whose source nanopub was invalidated.
2338
                DELETE { GRAPH <%2$s> { ?l ?p ?o . } }
2339
                WHERE {
2340
                  GRAPH <%2$s> {
2341
                    ?l a npa:SubSpaceLink ;
2342
                       npa:viaNanopub ?np .
2343
                    ?l ?p ?o .
2344
                  }
2345
                  GRAPH <%3$s> {
2346
                    ?invNp <%4$s> ?np ;
2347
                           npa:hasLoadNumber ?ln .
2348
                    FILTER (?ln > %5$d)
2349
                    %6$s
2350
                  }
2351
                } ;
2352
                # 2. Orphan-sweep isSubSpaceOf edges (ref- and IRI-valued) with no backing link.
2353
                DELETE { GRAPH <%2$s> { ?c npa:isSubSpaceOf ?p . } }
2354
                WHERE {
2355
                  GRAPH <%2$s> {
2356
                    ?c npa:isSubSpaceOf ?p .
2357
                    FILTER NOT EXISTS {
2358
                      { ?l a npa:SubSpaceLink } UNION { ?l a npa:DerivedSubSpaceLink }
2359
                      { { ?l npa:childSpaceRef ?c . ?l npa:parentSpaceRef ?p }
2360
                        UNION
2361
                        { ?l npa:childSpace ?c . ?l npa:parentSpace ?p } }
2362
                    }
2363
                  }
2364
                } ;
2365
                # 3. Orphan-sweep the inverse hasSubSpace edges symmetrically.
2366
                DELETE { GRAPH <%2$s> { ?p npa:hasSubSpace ?c . } }
2367
                WHERE {
2368
                  GRAPH <%2$s> {
2369
                    ?p npa:hasSubSpace ?c .
2370
                    FILTER NOT EXISTS {
2371
                      { ?l a npa:SubSpaceLink } UNION { ?l a npa:DerivedSubSpaceLink }
2372
                      { { ?l npa:childSpaceRef ?c . ?l npa:parentSpaceRef ?p }
2373
                        UNION
2374
                        { ?l npa:childSpace ?c . ?l npa:parentSpace ?p } }
2375
                    }
2376
                  }
2377
                }
2378
                """, NPA.NAMESPACE, graph, NPA.GRAPH, NPX.INVALIDATES, lastProcessed,
18✔
2379
                samePublisherClause("invNp", "np"));
6✔
2380
    }
2381

2382
    /**
2383
     * Convenience-edge cleanup for invalidated maintained-resource declarations (issue
2384
     * #125 finding #5). Run after {@link #maintainedResourceInvalidationDelete}. Deletes
2385
     * the {@code npa:MaintainedResourceLink} provenance links whose source nanopub was
2386
     * invalidated, then orphan-sweeps the {@code npa:isMaintainedBy} /
2387
     * {@code npa:hasMaintainedResource} edges (ref- and IRI-valued) that no surviving link
2388
     * backs. See {@link #subSpaceConvenienceEdgeCleanup} for the two-phase structure.
2389
     */
2390
    static String maintainedResourceConvenienceEdgeCleanup(IRI graph, long lastProcessed) {
2391
        return String.format("""
72✔
2392
                PREFIX npa: <%1$s>
2393
                # 1. Drop maintained-resource provenance links whose source nanopub was invalidated.
2394
                DELETE { GRAPH <%2$s> { ?l ?p ?o . } }
2395
                WHERE {
2396
                  GRAPH <%2$s> {
2397
                    ?l a npa:MaintainedResourceLink ;
2398
                       npa:viaNanopub ?np .
2399
                    ?l ?p ?o .
2400
                  }
2401
                  GRAPH <%3$s> {
2402
                    ?invNp <%4$s> ?np ;
2403
                           npa:hasLoadNumber ?ln .
2404
                    FILTER (?ln > %5$d)
2405
                    %6$s
2406
                  }
2407
                } ;
2408
                # 2. Orphan-sweep isMaintainedBy edges (ref- and IRI-valued) with no backing link.
2409
                DELETE { GRAPH <%2$s> { ?r npa:isMaintainedBy ?o . } }
2410
                WHERE {
2411
                  GRAPH <%2$s> {
2412
                    ?r npa:isMaintainedBy ?o .
2413
                    FILTER NOT EXISTS {
2414
                      ?l a npa:MaintainedResourceLink ;
2415
                         npa:resourceIri ?r .
2416
                      { ?l npa:maintainerSpaceRef ?o } UNION { ?l npa:maintainerSpace ?o }
2417
                    }
2418
                  }
2419
                } ;
2420
                # 3. Orphan-sweep the inverse hasMaintainedResource edges symmetrically.
2421
                DELETE { GRAPH <%2$s> { ?o npa:hasMaintainedResource ?r . } }
2422
                WHERE {
2423
                  GRAPH <%2$s> {
2424
                    ?o npa:hasMaintainedResource ?r .
2425
                    FILTER NOT EXISTS {
2426
                      ?l a npa:MaintainedResourceLink ;
2427
                         npa:resourceIri ?r .
2428
                      { ?l npa:maintainerSpaceRef ?o } UNION { ?l npa:maintainerSpace ?o }
2429
                    }
2430
                  }
2431
                } ;
2432
                # 4. Orphan-sweep the maintained arm of hasGoverningSpaceRef (issue #130).
2433
                #    Only the ref-valued maintained edge is removed here — it is backed by a
2434
                #    MaintainedResourceLink. The reflexive space self-edge (subject = a space
2435
                #    IRI that has its own SpaceRef) is NOT a maintained edge and is left to the
2436
                #    self-healing reflexive pass, so the guard keeps any ?r that is itself a space.
2437
                DELETE { GRAPH <%2$s> { ?r npa:hasGoverningSpaceRef ?o . } }
2438
                WHERE {
2439
                  GRAPH <%2$s> {
2440
                    ?r npa:hasGoverningSpaceRef ?o .
2441
                    FILTER NOT EXISTS {
2442
                      ?l a npa:MaintainedResourceLink ;
2443
                         npa:resourceIri ?r ;
2444
                         npa:maintainerSpaceRef ?o .
2445
                    }
2446
                    FILTER NOT EXISTS { GRAPH <%7$s> { ?o npa:spaceIri ?r . } }
2447
                  }
2448
                }
2449
                """, NPA.NAMESPACE, graph, NPA.GRAPH, NPX.INVALIDATES, lastProcessed,
18✔
2450
                samePublisherClause("invNp", "np"), SpacesVocab.SPACES_GRAPH);
18✔
2451
    }
2452

2453
    /**
2454
     * Convenience-edge cleanup for invalidated space-alias declarations (issue #125
2455
     * finding #5 — the load-bearing case, since the alias edge feeds the admin-authority
2456
     * closure). Run after {@link #aliasInvalidationDelete}. Deletes the
2457
     * {@code npa:SpaceAliasLink} provenance links whose source nanopub was invalidated,
2458
     * then orphan-sweeps the {@code npa:sameAsSpace} edges (ref- and IRI-valued) that no
2459
     * surviving link backs. See {@link #subSpaceConvenienceEdgeCleanup} for the two-phase
2460
     * structure.
2461
     */
2462
    static String aliasConvenienceEdgeCleanup(IRI graph, long lastProcessed) {
2463
        return String.format("""
72✔
2464
                PREFIX npa: <%1$s>
2465
                # 1. Drop alias provenance links whose source nanopub was invalidated.
2466
                DELETE { GRAPH <%2$s> { ?l ?p ?o . } }
2467
                WHERE {
2468
                  GRAPH <%2$s> {
2469
                    ?l a npa:SpaceAliasLink ;
2470
                       npa:viaNanopub ?np .
2471
                    ?l ?p ?o .
2472
                  }
2473
                  GRAPH <%3$s> {
2474
                    ?invNp <%4$s> ?np ;
2475
                           npa:hasLoadNumber ?ln .
2476
                    FILTER (?ln > %5$d)
2477
                    %6$s
2478
                  }
2479
                } ;
2480
                # 2. Orphan-sweep sameAsSpace edges (ref- and IRI-valued) with no backing link.
2481
                DELETE { GRAPH <%2$s> { ?alias npa:sameAsSpace ?o . } }
2482
                WHERE {
2483
                  GRAPH <%2$s> {
2484
                    ?alias npa:sameAsSpace ?o .
2485
                    FILTER NOT EXISTS {
2486
                      ?l a npa:SpaceAliasLink ;
2487
                         npa:aliasSpace ?alias .
2488
                      { ?l npa:canonicalSpaceRef ?o } UNION { ?l npa:canonicalSpace ?o }
2489
                    }
2490
                  }
2491
                }
2492
                """, NPA.NAMESPACE, graph, NPA.GRAPH, NPX.INVALIDATES, lastProcessed,
18✔
2493
                samePublisherClause("invNp", "np"));
6✔
2494
    }
2495

2496
    /**
2497
     * WHERE clause shared by the preset-deactivation ASK precheck and the matching DELETE
2498
     * (Nanodash issue #302). Binds {@code ?ra} = a materialized preset-derived
2499
     * {@code gen:RoleAssignment} ({@code npa:derivedFromPreset}) for which a <em>newer,
2500
     * admin-authored</em> same-{@code (preset, resource)} assignment exists by
2501
     * {@code dct:created} (load number in {@code (lastProcessed, ∞)}). This is NOT an
2502
     * {@code npx:invalidates} check — preset activation is latest-wins by timestamp.
2503
     *
2504
     * <p>Authorization-scoped (anti-hijack, design doc §3/§4.4): the newer assignment's
2505
     * publisher must itself be a validated admin of the row's {@code npa:forSpaceRef}, so an
2506
     * unauthorized key's newer assignment can neither delete nor shadow an admin's
2507
     * materialized role. {@code dct:created} is written as a full IRI (not a {@code dct:}
2508
     * prefix) because {@link #wouldInvalidate}'s ASK wrapper only declares {@code npa:} /
2509
     * {@code gen:}.
2510
     */
2511
    static String presetDeactivationCheckWhere(IRI graph, long lastProcessed) {
2512
        return String.format("""
60✔
2513
                  GRAPH <%1$s> {
2514
                    ?ra a gen:RoleAssignment ;
2515
                        npa:derivedFromPreset ?assignNp ;
2516
                        npa:forSpaceRef ?targetRef .
2517
                  }
2518
                  GRAPH <%2$s> {
2519
                    ?pa a npa:PresetAssignment ;
2520
                        npa:viaNanopub  ?assignNp ;
2521
                        npa:ofPreset    ?preset ;
2522
                        npa:forResource ?resource ;
2523
                        <http://purl.org/dc/terms/created> ?created .
2524
                    ?paNewer a npa:PresetAssignment ;
2525
                             npa:ofPreset    ?preset ;
2526
                             npa:forResource ?resource ;
2527
                             npa:pubkeyHash  ?pkhNewer ;
2528
                             npa:viaNanopub  ?assignNpNewer ;
2529
                             <http://purl.org/dc/terms/created> ?createdNewer .
2530
                    FILTER (?createdNewer > ?created
2531
                            || (?createdNewer = ?created && STR(?paNewer) > STR(?pa)))
2532
                  }
2533
                  GRAPH <%3$s> {
2534
                    ?assignNpNewer npa:hasLoadNumber ?lnNewer .
2535
                    FILTER (?lnNewer > %4$d)
2536
                  }
2537
                  GRAPH <%1$s> {
2538
                    ?acctNewer a npa:AccountState ;
2539
                               npa:agent  ?publisherNewer ;
2540
                               npa:pubkey ?pkhNewer .
2541
                    ?adminRINewer a gen:RoleInstantiation ;
2542
                                  npa:forSpaceRef ?targetRef ;
2543
                                  npa:inverseProperty gen:hasAdmin ;
2544
                                  npa:forAgent ?publisherNewer .
2545
                  }
2546
                """, graph, SpacesVocab.SPACES_GRAPH, NPA.GRAPH, lastProcessed);
6✔
2547
    }
2548

2549
    /**
2550
     * DELETE template for preset-derived {@code gen:RoleAssignment} rows superseded by a
2551
     * newer admin-authored same-pair assignment (issue #302). Removes the whole row by
2552
     * subject; scoped via {@code npa:derivedFromPreset} so directly-published attachments
2553
     * are never touched. The {@link #presetAttachmentValidationUpdate} re-INSERT in the
2554
     * same cycle re-materializes the pair iff the newest assignment is still active.
2555
     */
2556
    static String presetDeactivationDelete(IRI graph, long lastProcessed) {
2557
        return String.format("""
63✔
2558
                PREFIX npa: <%1$s>
2559
                PREFIX gen: <%2$s>
2560
                DELETE { GRAPH <%3$s> {
2561
                  ?ra ?p ?o .
2562
                } }
2563
                WHERE {
2564
                  GRAPH <%3$s> { ?ra ?p ?o . }
2565
                %4$s
2566
                }
2567
                """, NPA.NAMESPACE, GEN.NAMESPACE, graph,
2568
                presetDeactivationCheckWhere(graph, lastProcessed));
6✔
2569
    }
2570

2571
    /**
2572
     * DELETE template for ref-scoped preset-assignment stamps ({@link
2573
     * #presetAssignmentRefStampUpdate}) whose underlying assignment nanopub was
2574
     * hard-retracted (issue #122). Removes the whole row by subject; scoped to
2575
     * state-graph {@code npa:PresetAssignment} rows that carry {@code npa:forSpaceRef}
2576
     * (the IRI-keyed extraction rows never do), so it can never touch them.
2577
     *
2578
     * <p>Leaf delete — no structural flag: nothing downstream derives from a listing
2579
     * stamp, so a stale row only mis-displays a retracted assignment until this cycle's
2580
     * delete runs. Admin-grant revocation is bounded by the periodic full rebuild (same
2581
     * sticky-convenience policy as the alias / sub-space declaration edges). A
2582
     * <em>deactivation</em> needs no delete here: it is represented as a newer
2583
     * admin-authored stamp with {@code npa:isActivated false}, resolved by the consumer's
2584
     * latest-wins.
2585
     */
2586
    static String presetAssignmentRefInvalidationDelete(IRI graph, long lastProcessed) {
2587
        return String.format("""
84✔
2588
                PREFIX npa: <%1$s>
2589
                PREFIX gen: <%2$s>
2590
                DELETE { GRAPH <%3$s> {
2591
                  ?paRef ?p ?o .
2592
                } }
2593
                WHERE {
2594
                  GRAPH <%3$s> {
2595
                    ?paRef a npa:PresetAssignment ;
2596
                           npa:forSpaceRef ?targetRef ;
2597
                           npa:viaNanopub  ?assignNp .
2598
                    ?paRef ?p ?o .
2599
                  }
2600
                  GRAPH <%4$s> {
2601
                    ?invNp <%5$s> ?assignNp ;
2602
                           npa:hasLoadNumber ?ln .
2603
                    FILTER (?ln > %6$d)
2604
                    %7$s
2605
                  }
2606
                }
2607
                """, NPA.NAMESPACE, GEN.NAMESPACE, graph,
2608
                NPA.GRAPH, NPX.INVALIDATES, lastProcessed,
18✔
2609
                samePublisherClause("invNp", "assignNp"));
6✔
2610
    }
2611

2612
    /** Wraps an ASK by joining the shared prefixes. */
2613
    private boolean wouldInvalidate(IRI graph, long lastProcessed,
2614
                                    boolean adminPinned, String whereClause) {
2615
        // adminPinned is informational only — kept to make call sites read clearly;
2616
        // the WHERE clause already encodes the kind via its own type predicates.
2617
        String ask = String.format("""
×
2618
                PREFIX npa: <%1$s>
2619
                PREFIX gen: <%2$s>
2620
                ASK { %3$s }
2621
                """, NPA.NAMESPACE, GEN.NAMESPACE, whereClause);
2622
        return runAsk(ask);
×
2623
    }
2624

2625
    private boolean runAsk(String sparql) {
2626
        try (RepositoryConnection conn = TripleStore.get().getRepoConnection(SPACES_REPO)) {
×
2627
            return conn.prepareBooleanQuery(QueryLanguage.SPARQL, sparql).evaluate();
×
2628
        }
2629
    }
2630

2631
    private void executeUpdate(String sparqlUpdate) {
2632
        try (RepositoryConnection conn = TripleStore.get().getRepoConnection(SPACES_REPO)) {
×
2633
            conn.prepareUpdate(QueryLanguage.SPARQL, sparqlUpdate).execute();
×
2634
        }
2635
    }
×
2636

2637
    // ---------------- Mirror step ----------------
2638

2639
    /**
2640
     * Copies trust-approved {@code npa:AccountState} rows from {@code npat:<T>}
2641
     * in the {@code trust} repo into {@code newGraph} in the {@code spaces} repo,
2642
     * inside one spaces-side serializable transaction.
2643
     *
2644
     * @return number of rows mirrored (useful for metrics / logging)
2645
     */
2646
    int mirrorTrustState(String trustStateHash, IRI newGraph) {
2647
        IRI trustStateIri = NPAT.forHash(trustStateHash);
×
2648
        int count = 0;
×
2649
        try (RepositoryConnection trustConn = TripleStore.get().getRepoConnection(TRUST_REPO);
×
2650
             RepositoryConnection spacesConn = TripleStore.get().getRepoConnection(SPACES_REPO)) {
×
2651
            trustConn.begin(IsolationLevels.READ_COMMITTED);
×
2652
            spacesConn.begin(IsolationLevels.SERIALIZABLE);
×
2653
            // Walk rdf:type triples in the trust state's graph; for each AccountState,
2654
            // check status and copy the approved ones verbatim (minus status-specific
2655
            // detail triples, which we don't need for validation).
2656
            try (RepositoryResult<Statement> typeRows = trustConn.getStatements(
×
2657
                    null, RDF.TYPE, NPA_ACCOUNT_STATE, trustStateIri)) {
2658
                while (typeRows.hasNext()) {
×
2659
                    Statement st = typeRows.next();
×
2660
                    if (!(st.getSubject() instanceof IRI accountStateIri)) continue;
×
2661
                    Value status = trustConn.getStatements(accountStateIri, NPA_TRUST_STATUS, null, trustStateIri)
×
2662
                            .stream().findFirst().map(Statement::getObject).orElse(null);
×
2663
                    if (!(status instanceof IRI statusIri) || !APPROVED_SET.contains(statusIri)) continue;
×
2664
                    Value agent = trustConn.getStatements(accountStateIri, NPA_AGENT, null, trustStateIri)
×
2665
                            .stream().findFirst().map(Statement::getObject).orElse(null);
×
2666
                    Value pubkey = trustConn.getStatements(accountStateIri, NPA_PUBKEY, null, trustStateIri)
×
2667
                            .stream().findFirst().map(Statement::getObject).orElse(null);
×
2668
                    if (agent == null || pubkey == null) {
×
2669
                        logger.warn("AuthorityResolver.mirror: account {} missing agent or pubkey; skipping",
×
2670
                                accountStateIri);
2671
                        continue;
×
2672
                    }
2673
                    spacesConn.add(accountStateIri, RDF.TYPE, NPA_ACCOUNT_STATE, newGraph);
×
2674
                    spacesConn.add(accountStateIri, NPA_AGENT, agent, newGraph);
×
2675
                    spacesConn.add(accountStateIri, NPA_PUBKEY, pubkey, newGraph);
×
2676
                    spacesConn.add(accountStateIri, NPA_TRUST_STATUS, statusIri, newGraph);
×
2677
                    count++;
×
2678
                }
×
2679
            }
2680
            // Mirror canonical foaf:name triples for approved agents. The trust
2681
            // loader emits one per agent (across approved keys, MAX(ratio) wins).
2682
            // Copying them into the space-state graph means consumers reading
2683
            // ?agent foaf:name ?n inside the state graph hit local data, with no
2684
            // cross-repo SERVICE.
2685
            try (RepositoryResult<Statement> nameRows = trustConn.getStatements(
×
2686
                    null, FOAF.NAME, null, trustStateIri)) {
2687
                while (nameRows.hasNext()) {
×
2688
                    Statement st = nameRows.next();
×
2689
                    spacesConn.add(st.getSubject(), st.getPredicate(), st.getObject(), newGraph);
×
2690
                }
×
2691
            }
2692
            spacesConn.commit();
×
2693
            trustConn.commit();
×
2694
        }
2695
        return count;
×
2696
    }
2697

2698
    // ---------------- Pointer + counter helpers ----------------
2699

2700
    /**
2701
     * Reads the current {@code npa:hasCurrentSpaceState} pointer from the
2702
     * {@code npa:graph} admin graph of the {@code spaces} repo. Returns
2703
     * {@code null} if no pointer exists yet.
2704
     */
2705
    IRI getCurrentSpaceStateGraph() {
2706
        try (RepositoryConnection conn = TripleStore.get().getRepoConnection(SPACES_REPO)) {
×
2707
            Value v = Utils.getObjectForPattern(conn, NPA.GRAPH, NPA.THIS_REPO,
×
2708
                    SpacesVocab.HAS_CURRENT_SPACE_STATE);
2709
            return (v instanceof IRI iri) ? iri : null;
×
2710
        } catch (Exception ex) {
×
2711
            logger.warn("AuthorityResolver: failed to read hasCurrentSpaceState pointer: {}", ex.toString());
×
2712
            return null;
×
2713
        }
2714
    }
2715

2716
    long getCurrentLoadCounter() {
2717
        try (RepositoryConnection conn = TripleStore.get().getRepoConnection(SPACES_REPO)) {
×
2718
            Value v = Utils.getObjectForPattern(conn, NPA.GRAPH, NPA.THIS_REPO,
×
2719
                    SpacesVocab.CURRENT_LOAD_COUNTER);
2720
            if (v == null) return 0;
×
2721
            try {
2722
                return Long.parseLong(v.stringValue());
×
2723
            } catch (NumberFormatException ex) {
×
2724
                logger.warn("AuthorityResolver: non-numeric currentLoadCounter: {}", v);
×
2725
                return 0;
×
2726
            }
2727
        } catch (Exception ex) {
×
2728
            logger.warn("AuthorityResolver: failed to read currentLoadCounter: {}", ex.toString());
×
2729
            return 0;
×
2730
        }
2731
    }
2732

2733
    /**
2734
     * Atomic pointer flip: a single SPARQL {@code DELETE … INSERT … WHERE}
2735
     * replaces the old pointer with the new one in one statement, so readers
2736
     * never see a zero-pointer window.
2737
     */
2738
    void flipPointer(IRI newGraph) {
2739
        String update = String.format("""
×
2740
                DELETE { GRAPH <%s> { <%s> <%s> ?old } }
2741
                INSERT { GRAPH <%s> { <%s> <%s> <%s> } }
2742
                WHERE  { OPTIONAL { GRAPH <%s> { <%s> <%s> ?old } } }
2743
                """,
2744
                NPA.GRAPH, NPA.THIS_REPO, SpacesVocab.HAS_CURRENT_SPACE_STATE,
2745
                NPA.GRAPH, NPA.THIS_REPO, SpacesVocab.HAS_CURRENT_SPACE_STATE, newGraph,
2746
                NPA.GRAPH, NPA.THIS_REPO, SpacesVocab.HAS_CURRENT_SPACE_STATE);
2747
        try (RepositoryConnection conn = TripleStore.get().getRepoConnection(SPACES_REPO)) {
×
2748
            conn.begin(IsolationLevels.SERIALIZABLE);
×
2749
            conn.prepareUpdate(QueryLanguage.SPARQL, update).execute();
×
2750
            conn.commit();
×
2751
        }
2752
    }
×
2753

2754
    void writeProcessedUpTo(IRI graph, long loadCounter) {
2755
        String update = String.format("""
×
2756
                DELETE { GRAPH <%s> { <%s> <%s> ?old } }
2757
                INSERT { GRAPH <%s> { <%s> <%s> "%d"^^<http://www.w3.org/2001/XMLSchema#long> } }
2758
                WHERE  { OPTIONAL { GRAPH <%s> { <%s> <%s> ?old } } }
2759
                """,
2760
                graph, graph, SpacesVocab.PROCESSED_UP_TO,
2761
                graph, graph, SpacesVocab.PROCESSED_UP_TO, loadCounter,
×
2762
                graph, graph, SpacesVocab.PROCESSED_UP_TO);
2763
        try (RepositoryConnection conn = TripleStore.get().getRepoConnection(SPACES_REPO)) {
×
2764
            conn.begin(IsolationLevels.SERIALIZABLE);
×
2765
            conn.prepareUpdate(QueryLanguage.SPARQL, update).execute();
×
2766
            conn.commit();
×
2767
        }
2768
    }
×
2769

2770
    /**
2771
     * Reads {@code processedUpTo} from the given space-state graph.
2772
     * Returns {@code -1} if absent (graph not fully built yet).
2773
     */
2774
    long readProcessedUpTo(IRI graph) {
2775
        try (RepositoryConnection conn = TripleStore.get().getRepoConnection(SPACES_REPO)) {
×
2776
            String query = String.format(
×
2777
                    "SELECT ?n WHERE { GRAPH <%s> { <%s> <%s> ?n } }",
2778
                    graph, graph, SpacesVocab.PROCESSED_UP_TO);
2779
            try (TupleQueryResult r = conn.prepareTupleQuery(QueryLanguage.SPARQL, query).evaluate()) {
×
2780
                if (!r.hasNext()) return -1;
×
2781
                BindingSet b = r.next();
×
2782
                return Long.parseLong(b.getBinding("n").getValue().stringValue());
×
2783
            }
×
2784
        } catch (Exception ex) {
×
2785
            logger.warn("AuthorityResolver: failed to read processedUpTo for {}: {}", graph, ex.toString());
×
2786
            return -1;
×
2787
        }
2788
    }
2789

2790
    /**
2791
     * Reads the {@code npa:needsFullRebuild} flag (boolean literal) from
2792
     * {@code npa:graph} in the {@code spaces} repo. Defaults to {@code false}
2793
     * when the triple is absent.
2794
     */
2795
    boolean readNeedsFullRebuild() {
2796
        try (RepositoryConnection conn = TripleStore.get().getRepoConnection(SPACES_REPO)) {
×
2797
            Value v = Utils.getObjectForPattern(conn, NPA.GRAPH, NPA.THIS_REPO,
×
2798
                    SpacesVocab.NEEDS_FULL_REBUILD);
2799
            return v != null && Boolean.parseBoolean(v.stringValue());
×
2800
        } catch (Exception ex) {
×
2801
            logger.warn("AuthorityResolver: failed to read needsFullRebuild: {}", ex.toString());
×
2802
            return false;
×
2803
        }
2804
    }
2805

2806
    void setNeedsFullRebuild() {
2807
        writeNeedsFullRebuild(true);
×
2808
    }
×
2809

2810
    void clearNeedsFullRebuild() {
2811
        writeNeedsFullRebuild(false);
×
2812
    }
×
2813

2814
    private void writeNeedsFullRebuild(boolean value) {
2815
        String update = String.format("""
×
2816
                DELETE { GRAPH <%s> { <%s> <%s> ?old } }
2817
                INSERT { GRAPH <%s> { <%s> <%s> "%s"^^<http://www.w3.org/2001/XMLSchema#boolean> } }
2818
                WHERE  { OPTIONAL { GRAPH <%s> { <%s> <%s> ?old } } }
2819
                """,
2820
                NPA.GRAPH, NPA.THIS_REPO, SpacesVocab.NEEDS_FULL_REBUILD,
2821
                NPA.GRAPH, NPA.THIS_REPO, SpacesVocab.NEEDS_FULL_REBUILD, value,
×
2822
                NPA.GRAPH, NPA.THIS_REPO, SpacesVocab.NEEDS_FULL_REBUILD);
2823
        try (RepositoryConnection conn = TripleStore.get().getRepoConnection(SPACES_REPO)) {
×
2824
            conn.begin(IsolationLevels.SERIALIZABLE);
×
2825
            conn.prepareUpdate(QueryLanguage.SPARQL, update).execute();
×
2826
            conn.commit();
×
2827
        }
2828
    }
×
2829

2830
    void dropGraph(IRI graph) {
2831
        try (RepositoryConnection conn = TripleStore.get().getRepoConnection(SPACES_REPO)) {
×
2832
            conn.begin(IsolationLevels.SERIALIZABLE);
×
2833
            conn.clear(graph);
×
2834
            conn.commit();
×
2835
            logger.info("AuthorityResolver: dropped old space-state graph {}", graph);
×
2836
        }
2837
    }
×
2838

2839
    // ---------------- Trust-repo pointer lookup (used by TrustStateRegistry's bootstrap) ----------------
2840

2841
    /**
2842
     * Queries the {@code trust} repo directly for the current trust-state hash.
2843
     * Prefer {@link TrustStateRegistry#getCurrentHash()} in normal operation —
2844
     * this helper exists for tests and diagnostics.
2845
     *
2846
     * @return the current trust-state hash, or empty if none is set
2847
     */
2848
    Optional<String> readTrustRepoCurrentHash() {
2849
        try (RepositoryConnection conn = TripleStore.get().getRepoConnection(TRUST_REPO)) {
×
2850
            Value v = Utils.getObjectForPattern(conn, NPA.GRAPH, NPA.THIS_REPO,
×
2851
                    NPA_HAS_CURRENT_TRUST_STATE);
2852
            if (!(v instanceof IRI iri)) return Optional.empty();
×
2853
            String s = iri.stringValue();
×
2854
            if (!s.startsWith(NPAT.NAMESPACE)) return Optional.empty();
×
2855
            return Optional.of(s.substring(NPAT.NAMESPACE.length()));
×
2856
        } catch (Exception ex) {
×
2857
            logger.warn("AuthorityResolver: failed to read trust-repo current pointer: {}", ex.toString());
×
2858
            return Optional.empty();
×
2859
        }
2860
    }
2861

2862
    private static String abbrev(String hash) {
2863
        return hash.length() > 12 ? hash.substring(0, 12) + "…" : hash;
×
2864
    }
2865

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

© 2026 Coveralls, Inc