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

knowledgepixels / nanopub-query / 28225737140

26 Jun 2026 08:09AM UTC coverage: 61.856% (+0.8%) from 61.063%
28225737140

push

github

web-flow
Merge pull request #134 from knowledgepixels/feat/issue-129-role-revocation

feat(spaces): revoke & re-assign space roles (authorization-scoped latest-wins) (#129)

569 of 1026 branches covered (55.46%)

Branch coverage included in aggregate %.

1677 of 2605 relevant lines covered (64.38%)

10.03 hits per line

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

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

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

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

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

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

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

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

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

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

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

85
    private static AuthorityResolver instance;
86

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

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

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

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

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

119
    // ---------------- Public entry points ----------------
120

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

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

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

206
    // ---------------- Full build ----------------
207

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

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

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

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

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

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

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

263
    // ---------------- Incremental cycle ----------------
264

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

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

332
        writeProcessedUpTo(graph, currentLoadCounter);
×
333

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

355
    /**
356
     * Runs the four invalidation-DELETE / ASK steps. Sets {@code npa:needsFullRebuild}
357
     * when admin-RI, RoleAssignment, or RoleDeclaration invalidations matched (the
358
     * three structural kinds). Leaf-tier RI deletes don't set the flag.
359
     *
360
     * @return true iff at least one structural kind was invalidated
361
     */
362
    boolean applyInvalidations(IRI graph, long lastProcessed) {
363
        boolean structural = false;
×
364
        if (wouldInvalidate(graph, lastProcessed, /*adminPinned=*/ true,
×
365
                            adminInvalidationCheckWhere(graph, lastProcessed))) {
×
366
            executeUpdate(adminInvalidationDelete(graph, lastProcessed));
×
367
            structural = true;
×
368
        }
369
        if (wouldInvalidate(graph, lastProcessed, /*adminPinned=*/ false,
×
370
                            roleAssignmentInvalidationCheckWhere(graph, lastProcessed))) {
×
371
            executeUpdate(roleAssignmentInvalidationDelete(graph, lastProcessed));
×
372
            structural = true;
×
373
        }
374
        // Role-declaration invalidation is deliberately NOT acted on (see
375
        // nonAdminTierUpdate): a role assignment is governed by the admin-validated
376
        // attachment, not by the declaration author's later supersession/retraction, so
377
        // an invalidated RD neither deletes rows nor triggers a rebuild.
378
        // Sub-space declarations are structural — invalidating one (Mode A) or one
379
        // of two co-declarations (Mode B) changes the validated parent/child
380
        // topology. The DELETE removes the per-declaration row; the convenience-edge
381
        // cleanup then drops the now-unbacked direct triples (issue #125 finding #5)
382
        // instead of leaving them sticky until the periodic rebuild. The structural
383
        // flag still fires so downstream rows derived through a removed edge stay
384
        // rebuild-bounded.
385
        if (wouldInvalidate(graph, lastProcessed, /*adminPinned=*/ false,
×
386
                            subSpaceInvalidationCheckWhere(graph, lastProcessed))) {
×
387
            executeUpdate(subSpaceInvalidationDelete(graph, lastProcessed));
×
388
            executeUpdate(subSpaceConvenienceEdgeCleanup(graph, lastProcessed));
×
389
            structural = true;
×
390
        }
391
        // Space-alias declarations are structural — invalidating one removes an
392
        // owl:sameAs edge that feeds the admin-authority closure (issue #113). The
393
        // DELETE removes the per-declaration row; the convenience-edge cleanup then
394
        // drops the now-unbacked npa:sameAsSpace edge (issue #125 finding #5 — the
395
        // load-bearing case), so admin authority can no longer outlive a retraction
396
        // until the next periodic rebuild.
397
        if (wouldInvalidate(graph, lastProcessed, /*adminPinned=*/ false,
×
398
                            aliasInvalidationCheckWhere(graph, lastProcessed))) {
×
399
            executeUpdate(aliasInvalidationDelete(graph, lastProcessed));
×
400
            executeUpdate(aliasConvenienceEdgeCleanup(graph, lastProcessed));
×
401
            structural = true;
×
402
        }
403
        // Preset-derived RoleAssignment removal (issue #302). NOT npx:invalidates: a newer
404
        // admin-authored same-(preset,resource) assignment supersedes by dct:created (a
405
        // gen:DeactivatedPresetAssignment, or any newer assignment that is no longer active).
406
        // Structural — sticky downstream non-admin RIs derived through a removed attachment
407
        // are bounded by the periodic full rebuild. The DELETE is scoped by
408
        // npa:derivedFromPreset so directly-published gen:hasRole attachments are never
409
        // touched; the §4.3 re-INSERT re-materializes only currently-active pairs in the same
410
        // cycle. See doc/design-preset-role-materialization.md §4.4.
411
        if (wouldInvalidate(graph, lastProcessed, /*adminPinned=*/ false,
×
412
                            presetDeactivationCheckWhere(graph, lastProcessed))) {
×
413
            executeUpdate(presetDeactivationDelete(graph, lastProcessed));
×
414
            structural = true;
×
415
        }
416
        // Admin role-instantiation revocation (issue #129). STRUCTURAL — admin RIs feed every
417
        // downstream tier, so a removed admin must bound the staleness via a full rebuild
418
        // (mirrors adminInvalidationDelete). Root admins are exempt inside the check-where.
419
        if (wouldInvalidate(graph, lastProcessed, /*adminPinned=*/ true,
×
420
                            adminRevocationCheckWhere(graph, lastProcessed))) {
×
421
            executeUpdate(adminRevocationDelete(graph, lastProcessed));
×
422
            structural = true;
×
423
        }
424
        // Role detachment (issue #129). STRUCTURAL — removing a (ref, role) attachment
425
        // (direct or preset-derived) cascades to the instantiations anchored on it, bounded
426
        // by the periodic full rebuild. The attachment-tier inline filters then keep the
427
        // detached role suppressed until a newer attachment / preset assignment out-ranks it.
428
        if (wouldInvalidate(graph, lastProcessed, /*adminPinned=*/ false,
×
429
                            roleDetachmentCheckWhere(graph, lastProcessed))) {
×
430
            executeUpdate(roleDetachmentDelete(graph, lastProcessed));
×
431
            structural = true;
×
432
        }
433
        // Non-admin role-instantiation revocation (issue #129), run once per tier so the
434
        // authorization arms are the compile-time set for that tier (mirrors the inline
435
        // suppression filter). STRUCTURAL: a revoked maintainer or member is a sub-granting
436
        // authority — members/observers they granted are validated via the maint-pub /
437
        // member-pub arms of nonAdminTierUpdate, so removing the revoked agent's own RI must
438
        // schedule a full rebuild to re-evaluate (and drop) those now-unauthorized downstream
439
        // grants. The inline suppression filter prevents re-materialization on that rebuild.
440
        for (IRI revTier : List.of(GEN.MAINTAINER_ROLE, GEN.MEMBER_ROLE, GEN.OBSERVER_ROLE)) {
×
441
            if (wouldInvalidate(graph, lastProcessed, /*adminPinned=*/ false,
×
442
                                roleRevocationCheckWhere(graph, lastProcessed, revTier))) {
×
443
                executeUpdate(roleRevocationDelete(graph, lastProcessed, revTier));
×
444
                structural = true;
×
445
            }
446
        }
×
447
        // Leaf-tier RI deletes — no flag.
448
        executeUpdate(leafTierInvalidationDelete(graph, lastProcessed));
×
449
        // Ref-scoped preset-assignment listing stamps whose assignment nanopub was
450
        // hard-retracted (issue #122) — no flag (display leaf, nothing downstream).
451
        executeUpdate(presetAssignmentRefInvalidationDelete(graph, lastProcessed));
×
452
        // Maintained-resource declaration deletes — no flag (leaf relation, no
453
        // downstream caches to bound). The per-declaration delete removes the row; the
454
        // convenience-edge cleanup drops the now-unbacked isMaintainedBy edges (issue
455
        // #125 finding #5). Guarded so the orphan-sweep only scans when something was
456
        // actually invalidated (the delete itself was already a no-op otherwise).
457
        if (wouldInvalidate(graph, lastProcessed, /*adminPinned=*/ false,
×
458
                            maintainedResourceInvalidationCheckWhere(graph, lastProcessed))) {
×
459
            executeUpdate(maintainedResourceInvalidationDelete(graph, lastProcessed));
×
460
            executeUpdate(maintainedResourceConvenienceEdgeCleanup(graph, lastProcessed));
×
461
        }
462
        if (structural) setNeedsFullRebuild();
×
463
        return structural;
×
464
    }
465

466
    /**
467
     * Runs the four leaf tiers (attachment/maintainer/member/observer) with
468
     * {@code lastProcessed = -1} so the load-number filter on the candidate
469
     * side admits everything. Dedup filters in the tier templates prevent
470
     * double-insert. Used by the late-arrival sweep.
471
     */
472
    TierInsertedTriples runDownstreamWithoutLoadFilter(IRI graph) {
473
        TierInsertedTriples c = new TierInsertedTriples();
×
474
        // Alias late-arrival: catches alias declarations whose canonical admin grant
475
        // became valid only in this same cycle (the load-number filter on the
476
        // declaration's nanopub would otherwise exclude it). Runs first so the
477
        // attachment / role tiers below see this cycle's fresh npa:sameAsSpace edges.
478
        c.alias = runTierLabeled("alias(late)", graph, aliasAdmitUpdate(graph, -1));
×
479
        // Sub-space late-arrival: catches Mode-B candidates whose primary
480
        // declaration is older than lastProcessed but whose partner just landed.
481
        c.subSpace = runTierLabeled("subspace(late)", graph,
×
482
                subSpaceAdmitUpdate(graph, -1));
×
483
        // Maintained-resource late-arrival: catches declarations that landed
484
        // before the publisher's admin grant became valid in this state.
485
        c.maintainedResource = runTierLabeled("maintained-resource(late)", graph,
×
486
                maintainedResourceAdmitUpdate(graph, -1));
×
487
        // URL-prefix fallback: re-run after the late-arrival sub-space admit so
488
        // any newly-validated children get their fallback edges suppressed (for
489
        // future inserts) and any newly-orphaned children pick up fallback edges.
490
        c.subSpacePrefix = runTierLabeled("subspace-prefix(late)", graph,
×
491
                subSpacePrefixFallbackUpdate(graph));
×
492
        // Reflexive governing-space-ref late sweep (issue #130): catches refs whose
493
        // SpaceRef aggregate became visible only this cycle. Self-healing dedup.
494
        c.governingSpaceRef = runTierLabeled("governing-space-ref(late)", graph,
×
495
                governingSpaceRefReflexiveUpdate(graph));
×
496
        // Preset-attachment late-arrival: catches assignments whose preset declaration or
497
        // admin grant only became valid in this same cycle. Runs before attachment(late)
498
        // so the non-admin late tiers below see this cycle's fresh preset-derived RAs.
499
        c.presetAttachment = runTierLabeled("preset-attachment(late)", graph,
×
500
                presetAttachmentValidationUpdate(graph, -1));
×
501
        // Ref-scoped preset-assignment late stamp: catches assignments whose authorizing
502
        // admin grant only became valid this cycle (the load filter would skip the older
503
        // assignment nanopub). Mirrors the preset-attachment late sweep above.
504
        c.presetAssignmentRef = runTierLabeled("preset-assignment-ref(late)", graph,
×
505
                presetAssignmentRefStampUpdate(graph, -1));
×
506
        c.attachment = runTierLabeled("attachment(late)", graph,
×
507
                attachmentValidationUpdate(graph, -1));
×
508
        c.maintainer = runTierLabeled("maintainer(late)", graph,
×
509
                nonAdminTierUpdate(graph, -1, GEN.MAINTAINER_ROLE, PUBLISHER_IS_ADMIN));
×
510
        c.member = runTierLabeled("member(admin-pub,late)", graph,
×
511
                nonAdminTierUpdate(graph, -1, GEN.MEMBER_ROLE, PUBLISHER_IS_ADMIN));
×
512
        c.member += runTierLabeled("member(maint-pub,late)", graph,
×
513
                nonAdminTierUpdate(graph, -1,
×
514
                        GEN.MEMBER_ROLE, publisherIsTieredRole(GEN.MAINTAINER_ROLE)));
×
515
        c.observer = runTierLabeled("observer(admin-pub,late)", graph,
×
516
                nonAdminTierUpdate(graph, -1, GEN.OBSERVER_ROLE, PUBLISHER_IS_ADMIN));
×
517
        c.observer += runTierLabeled("observer(maint-pub,late)", graph,
×
518
                nonAdminTierUpdate(graph, -1,
×
519
                        GEN.OBSERVER_ROLE, publisherIsTieredRole(GEN.MAINTAINER_ROLE)));
×
520
        c.observer += runTierLabeled("observer(member-pub,late)", graph,
×
521
                nonAdminTierUpdate(graph, -1,
×
522
                        GEN.OBSERVER_ROLE, publisherIsTieredRole(GEN.MEMBER_ROLE)));
×
523
        c.observer += runTierLabeled("observer(self,late)", graph,
×
524
                nonAdminTierUpdate(graph, -1, GEN.OBSERVER_ROLE, PUBLISHER_IS_SELF));
×
525
        return c;
×
526
    }
527

528
    /**
529
     * Cheap ASK: did any new {@code npa:RoleDeclaration} extraction land in the
530
     * load-number delta {@code (lastProcessed, ∞)}? Used by the late-arrival
531
     * trigger so an RD that arrives in the same cycle as a matching candidate
532
     * still gets validated.
533
     */
534
    boolean newRoleDeclarationsArrived(long lastProcessed) {
535
        String ask = String.format("""
×
536
                PREFIX npa: <%1$s>
537
                ASK {
538
                  GRAPH <%2$s> {
539
                    ?rd a npa:RoleDeclaration ;
540
                        npa:viaNanopub ?np .
541
                  }
542
                  GRAPH <%3$s> {
543
                    ?np npa:hasLoadNumber ?ln .
544
                    FILTER (?ln > %4$d)
545
                  }
546
                }
547
                """, NPA.NAMESPACE, SpacesVocab.SPACES_GRAPH, NPA.GRAPH, lastProcessed);
×
548
        return runAsk(ask);
×
549
    }
550

551
    /**
552
     * Cheap ASK: did any new {@code npa:PresetAssignment} or {@code npa:PresetDeclaration}
553
     * extraction land in the load-number delta {@code (lastProcessed, ∞)}? Drives the
554
     * late-arrival re-run so a preset assignment that arrives in the same cycle as its
555
     * declaration (or admin grant) still materializes, and so an arriving newer assignment
556
     * triggers the deactivation/latest-wins re-evaluation.
557
     */
558
    boolean newPresetAssignmentsArrived(long lastProcessed) {
559
        String ask = String.format("""
×
560
                PREFIX npa: <%1$s>
561
                ASK {
562
                  GRAPH <%2$s> {
563
                    ?x a ?t ;
564
                       npa:viaNanopub ?np .
565
                    FILTER (?t = npa:PresetAssignment || ?t = npa:PresetDeclaration)
566
                  }
567
                  GRAPH <%3$s> {
568
                    ?np npa:hasLoadNumber ?ln .
569
                    FILTER (?ln > %4$d)
570
                  }
571
                }
572
                """, NPA.NAMESPACE, SpacesVocab.SPACES_GRAPH, NPA.GRAPH, lastProcessed);
×
573
        return runAsk(ask);
×
574
    }
575

576
    // ---------------- Tier UPDATE loops ----------------
577

578
    /**
579
     * Per-tier inserted-triple tallies for one build or cycle. Counts the sum
580
     * of {@code (graphSize_after - graphSize_before)} across all iterations of
581
     * each tier's fixed-point INSERT loop — i.e. inserted *triples*, not
582
     * distinct subjects (a single RoleInstantiation insert writes 4–5 triples).
583
     *
584
     * <p>Used internally by the {@link #runIncrementalCycle structuralAdds}
585
     * boolean check (we only care whether any tier inserted at all).
586
     * Not what the log lines report: see {@link TierSubjectTotals} +
587
     * {@link #computeTierSubjectTotals} for the distinct-subject totals
588
     * surfaced to operators.
589
     */
590
    static final class TierInsertedTriples {
×
591
        int admin;
592
        int alias;
593
        int presetAttachment;
594
        int presetAssignmentRef;
595
        int attachment;
596
        int maintainer;
597
        int member;
598
        int observer;
599
        int subSpace;
600
        int subSpacePrefix;
601
        int maintainedResource;
602
        int governingSpaceRef;
603
    }
604

605
    /**
606
     * Snapshot of distinct-subject totals in a space-state graph at a moment
607
     * in time. Independent of which tier-loop added each subject.
608
     */
609
    record TierSubjectTotals(long adminRIs, long attachmentRAs, long nonAdminRIs) {}
36✔
610

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

688
    /**
689
     * Builds a publisher constraint requiring the publisher to be a validated holder
690
     * of the given tier's role (maintainer or member) in the target space.
691
     * Owns its own AccountState resolution so ?publisher is bound through the
692
     * targeted (pkh → agent) lookup rather than enumerated.
693
     */
694
    private static String publisherIsTieredRole(IRI tierClass) {
695
        // Re-keyed on the assignment's ref (alias → canonical already resolved by the
696
        // attachment tier). Relies on materialized non-admin RIs carrying their role
697
        // property (npa:regularProperty / npa:inverseProperty) — supplied by the
698
        // enrichment in nonAdminTierUpdate; without it this constraint matched nothing.
699
        return """
×
700
                ?acct a npa:AccountState ;
701
                      npa:pubkey ?pkh ;
702
                      npa:agent  ?publisher .
703
                ?tierRI a gen:RoleInstantiation ;
704
                        npa:forSpaceRef ?spaceRef ;
705
                        npa:forAgent ?publisher .
706
                ?rdT a npa:RoleDeclaration ;
707
                     npa:hasRoleType <%1$s> .
708
                { ?tierRI npa:regularProperty ?predT . ?rdT gen:hasRegularProperty ?predT . }
709
                UNION
710
                { ?tierRI npa:inverseProperty ?predT . ?rdT gen:hasInverseProperty ?predT . }
711
                """.formatted(tierClass);
×
712
    }
713

714
    // ---------------- Role revocation / detachment (issue #129) ----------------
715

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

727
    /** Inner {@code GRAPH} block matching a revoker who is a validated admin of {@code ?spaceRef}. */
728
    private static String revokerAdminGraphBlock(IRI graph) {
729
        return String.format("""
27✔
730
                GRAPH <%1$s> {
731
                  ?revAcct a npa:AccountState ; npa:pubkey ?revPkh ; npa:agent ?revAgent .
732
                  ?revRI a gen:RoleInstantiation ;
733
                         npa:forSpaceRef ?spaceRef ;
734
                         npa:inverseProperty gen:hasAdmin ;
735
                         npa:forAgent ?revAgent .
736
                }""", graph);
737
    }
738

739
    /** Inner {@code GRAPH} block matching a revoker who holds {@code tier} in {@code ?spaceRef}. */
740
    private static String revokerTierGraphBlock(IRI graph, IRI tier) {
741
        return String.format("""
39✔
742
                GRAPH <%1$s> {
743
                  ?revAcct a npa:AccountState ; npa:pubkey ?revPkh ; npa:agent ?revAgent .
744
                  ?revRI a gen:RoleInstantiation ;
745
                         npa:forSpaceRef ?spaceRef ;
746
                         npa:forAgent ?revAgent ;
747
                         npa:hasRoleType <%2$s> .
748
                }""", graph, tier);
749
    }
750

751
    /** Inner {@code GRAPH} block matching a self-revoke: the revoker's key belongs to {@code ?agent}. */
752
    private static String revokerSelfGraphBlock(IRI graph) {
753
        return String.format("""
27✔
754
                GRAPH <%1$s> {
755
                  ?revAcct a npa:AccountState ; npa:pubkey ?revPkh ; npa:agent ?agent .
756
                }""", graph);
757
    }
758

759
    /**
760
     * Authorization arms for an instantiation revocation targeting a <em>compile-time</em>
761
     * tier — issue #129's matrix, the single arm builder used by BOTH the inline suppression
762
     * filter and the (per-tier-scoped) displacement DELETE, so the two paths can never
763
     * authorize different revokers and flip-flop a row. UNION of only the arms the matrix
764
     * permits for {@code targetTier}: admin of {@code ?spaceRef} (revokes any non-admin); a
765
     * maintainer (member/observer targets); a member (observer target); plus the assignee
766
     * itself (self-leave, any tier). A revoker must hold a tier strictly higher than the
767
     * target. Compile-time selection (no runtime {@code ?tier} variable) deliberately avoids
768
     * the SPARQL pitfall where a {@code FILTER} inside a {@code UNION} branch cannot see a
769
     * {@code ?tier} bound in the enclosing group.
770
     */
771
    private static String revocationAuthorityArmsForTier(IRI graph, IRI targetTier) {
772
        List<String> arms = new ArrayList<>();
12✔
773
        arms.add("{ " + revokerAdminGraphBlock(graph) + " }");
18✔
774
        if (GEN.MEMBER_ROLE.equals(targetTier) || GEN.OBSERVER_ROLE.equals(targetTier)) {
24✔
775
            arms.add("{ " + revokerTierGraphBlock(graph, GEN.MAINTAINER_ROLE) + " }");
21✔
776
        }
777
        if (GEN.OBSERVER_ROLE.equals(targetTier)) {
12✔
778
            arms.add("{ " + revokerTierGraphBlock(graph, GEN.MEMBER_ROLE) + " }");
21✔
779
        }
780
        arms.add("{ " + revokerSelfGraphBlock(graph) + " }");
18✔
781
        return String.join("\nUNION\n", arms);
12✔
782
    }
783

784
    /**
785
     * Inline suppression filter for {@code nonAdminTierUpdate}: rejects a candidate
786
     * instantiation ({@code ?ri}, created {@code ?candCreated}) whose {@code (space, agent,
787
     * role)} key has a newer authorized {@code npa:RoleRevocation}, using
788
     * {@link #revocationAuthorityArmsForTier} for {@code targetTier} (the loop tier) — the same
789
     * builder the displacement DELETE uses, so suppression and re-materialization always agree.
790
     * The revocation's named space is matched against any IRI denoting {@code ?spaceRef}
791
     * (canonical or validated {@code owl:sameAs} alias, issue #113), so an alias-named
792
     * revocation is not a silent no-op. Latest-wins by {@code dct:created} ({@link #EPOCH_DT}
793
     * fallback) with an {@code STR()} subject tiebreak. Not wrapped in {@code invalidationFilter}:
794
     * per issue #129 the only un-revoke path is a newer positive re-assignment.
795
     */
796
    private static String nonAdminRevocationSuppressionFilter(IRI graph, IRI targetTier) {
797
        return String.format("""
51✔
798
                FILTER NOT EXISTS {
799
                  { GRAPH <%2$s> { ?spaceRef npa:spaceIri ?revSpace . } }
800
                  UNION
801
                  { GRAPH <%1$s> { ?revSpace npa:sameAsSpace ?spaceRef . } }
802
                  GRAPH <%2$s> {
803
                    ?rev a npa:RoleRevocation ;
804
                         npa:forSpace    ?revSpace ;
805
                         npa:forAgent    ?agent ;
806
                         npa:revokedRole ?role ;
807
                         npa:pubkeyHash  ?revPkh .
808
                    OPTIONAL { ?rev <http://purl.org/dc/terms/created> ?revCreatedRaw . }
809
                  }
810
                  BIND(COALESCE(?revCreatedRaw, %4$s) AS ?revCreated)
811
                  FILTER (?revCreated > ?candCreated
812
                          || (?revCreated = ?candCreated && STR(?rev) > STR(?ri)))
813
                  { %3$s }
814
                }""", graph, SpacesVocab.SPACES_GRAPH,
815
                revocationAuthorityArmsForTier(graph, targetTier), EPOCH_DT);
18✔
816
    }
817

818
    /**
819
     * Inline suppression filter for {@code adminTierUpdate}: rejects an admin instantiation
820
     * ({@code ?ri}, created {@code ?candCreated}) whose {@code (ref, agent)} key has a newer
821
     * authorized admin {@code npa:RoleRevocation} ({@code revokedRole = gen:AdminRole}) —
822
     * authorized by an admin of the ref (admins revoke admins) or by the agent itself
823
     * (self-leave). <b>Root admins are exempt</b> (issue #129/#110): a nested
824
     * {@code FILTER NOT EXISTS} on {@code npa:hasRootAdmin} makes any revocation against a
825
     * root admin structurally inert, overriding self-leave. {@code gen:AdminRole} resolves
826
     * via the {@code gen:} prefix the admin-tier template declares.
827
     */
828
    private static String adminRevocationSuppressionFilter(IRI graph) {
829
        return String.format("""
48✔
830
                FILTER NOT EXISTS {
831
                  FILTER NOT EXISTS { GRAPH <%2$s> {
832
                    ?rootDef a npa:SpaceDefinition ;
833
                             npa:forSpaceRef  ?spaceRef ;
834
                             npa:hasRootAdmin ?agent .
835
                  } }
836
                  { GRAPH <%2$s> { ?spaceRef npa:spaceIri ?revSpace . } }
837
                  UNION
838
                  { GRAPH <%1$s> { ?revSpace npa:sameAsSpace ?spaceRef . } }
839
                  GRAPH <%2$s> {
840
                    ?rev a npa:RoleRevocation ;
841
                         npa:forSpace    ?revSpace ;
842
                         npa:forAgent    ?agent ;
843
                         npa:revokedRole gen:AdminRole ;
844
                         npa:pubkeyHash  ?revPkh .
845
                    OPTIONAL { ?rev <http://purl.org/dc/terms/created> ?revCreatedRaw . }
846
                  }
847
                  BIND(COALESCE(?revCreatedRaw, %4$s) AS ?revCreated)
848
                  FILTER (?revCreated > ?candCreated
849
                          || (?revCreated = ?candCreated && STR(?rev) > STR(?ri)))
850
                  { %3$s }
851
                }""", graph, SpacesVocab.SPACES_GRAPH,
852
                "{ " + revokerAdminGraphBlock(graph) + " }\nUNION\n{ "
6✔
853
                        + revokerSelfGraphBlock(graph) + " }",
21✔
854
                EPOCH_DT);
855
    }
856

857
    /**
858
     * Inline suppression filter for the attachment tiers ({@code attachmentValidationUpdate}
859
     * and {@code presetAttachmentValidationUpdate}): rejects a {@code (targetRef, role)}
860
     * attachment whose effective timestamp ({@code ?<createdVar>}) is out-ranked by a newer
861
     * admin-authored {@code npa:RoleDetachment} (issue #129). Authority = admin of
862
     * {@code ?targetRef} (matching who may attach). Non-sticky latest-wins: a newer
863
     * attachment / preset assignment naturally re-attaches because its timestamp beats the
864
     * detachment. The detachment's named space is matched against any IRI denoting
865
     * {@code ?targetRef} (canonical or {@code owl:sameAs} alias).
866
     *
867
     * @param createdVar     bare name of the attachment's effective-created variable
868
     * @param attachSubjVar  bare name of the attachment subject variable (for the STR tiebreak)
869
     */
870
    private static String roleDetachmentSuppressionFilter(IRI graph, String createdVar, String attachSubjVar) {
871
        return String.format("""
75✔
872
                FILTER NOT EXISTS {
873
                  { GRAPH <%2$s> { ?targetRef npa:spaceIri ?detSpace . } }
874
                  UNION
875
                  { GRAPH <%1$s> { ?detSpace npa:sameAsSpace ?targetRef . } }
876
                  GRAPH <%2$s> {
877
                    ?det a npa:RoleDetachment ;
878
                         npa:forSpace    ?detSpace ;
879
                         npa:revokedRole ?role ;
880
                         npa:pubkeyHash  ?detPkh .
881
                    OPTIONAL { ?det <http://purl.org/dc/terms/created> ?detCreatedRaw . }
882
                  }
883
                  BIND(COALESCE(?detCreatedRaw, %5$s) AS ?detCreated)
884
                  FILTER (?detCreated > ?%3$s
885
                          || (?detCreated = ?%3$s && STR(?det) > STR(?%4$s)))
886
                  GRAPH <%1$s> {
887
                    ?detAcct a npa:AccountState ; npa:pubkey ?detPkh ; npa:agent ?detAgent .
888
                    ?detAdminRI a gen:RoleInstantiation ;
889
                                npa:forSpaceRef ?targetRef ;
890
                                npa:inverseProperty gen:hasAdmin ;
891
                                npa:forAgent ?detAgent .
892
                  }
893
                }""", graph, SpacesVocab.SPACES_GRAPH, createdVar, attachSubjVar, EPOCH_DT);
894
    }
895

896
    /** Wraps {@link #runTierLoop} with tier-name context for logs/exceptions. */
897
    private int runTierLabeled(String tier, IRI graph, String sparqlUpdate) {
898
        try {
899
            return runTierLoop(graph, sparqlUpdate);
×
900
        } catch (RuntimeException ex) {
×
901
            logger.error("AuthorityResolver: tier={} failed with SPARQL UPDATE:\n{}\n", tier, sparqlUpdate, ex);
×
902
            throw ex;
×
903
        }
904
    }
905

906
    /**
907
     * Runs a single tier's INSERT to fixed point. Counts rows by probing
908
     * graph size before/after each INSERT; stops when the size doesn't change.
909
     *
910
     * @return total number of triples inserted by this tier across all iterations
911
     */
912
    int runTierLoop(IRI graph, String sparqlUpdate) {
913
        int total = 0;
×
914
        long before = graphSize(graph);
×
915
        while (true) {
916
            // Note: no explicit transaction wrapping here. In tests we observed that
917
            // HTTPRepository's RDF4J-transaction protocol silently no-op'd cross-graph
918
            // SPARQL UPDATEs with UNION sub-patterns inside conn.begin()/commit(),
919
            // while the same UPDATE POSTed directly to /statements applied correctly.
920
            // A bare prepareUpdate().execute() takes the direct /statements path and
921
            // runs the UPDATE atomically per SPARQL 1.1 semantics — which is all we
922
            // need; there's nothing else to commit atomically alongside the UPDATE.
923
            try (RepositoryConnection conn = TripleStore.get().getRepoConnection(SPACES_REPO)) {
×
924
                conn.prepareUpdate(QueryLanguage.SPARQL, sparqlUpdate).execute();
×
925
            }
926
            long after = graphSize(graph);
×
927
            long added = after - before;
×
928
            if (added <= 0) break;
×
929
            total += added;
×
930
            before = after;
×
931
        }
×
932
        return total;
×
933
    }
934

935
    private long graphSize(IRI graph) {
936
        try (RepositoryConnection conn = TripleStore.get().getRepoConnection(SPACES_REPO)) {
×
937
            return conn.size(graph);
×
938
        }
939
    }
940

941
    /**
942
     * Distinct-subject totals in the given space-state graph, broken down by
943
     * RoleInstantiation kind (admin-pinned vs not) and RoleAssignment.
944
     * Three SELECT-COUNT queries — cheap, called once per build/cycle for
945
     * the user-facing log line. Returns zeros on failure (logged) so a flaky
946
     * count read can't wedge the cycle.
947
     */
948
    TierSubjectTotals computeTierSubjectTotals(IRI graph) {
949
        long adminRIs       = countDistinctSubjects(graph, """
×
950
                ?ri a gen:RoleInstantiation ; npa:inverseProperty gen:hasAdmin .
951
                """, "ri");
952
        long attachmentRAs  = countDistinctSubjects(graph, """
×
953
                ?ra a gen:RoleAssignment .
954
                """, "ra");
955
        long nonAdminRIs    = countDistinctSubjects(graph, """
×
956
                ?ri a gen:RoleInstantiation .
957
                FILTER NOT EXISTS { ?ri npa:inverseProperty gen:hasAdmin }
958
                """, "ri");
959
        return new TierSubjectTotals(adminRIs, attachmentRAs, nonAdminRIs);
×
960
    }
961

962
    private long countDistinctSubjects(IRI graph, String wherePattern, String varName) {
963
        String query = String.format("""
×
964
                PREFIX npa: <%1$s>
965
                PREFIX gen: <%2$s>
966
                SELECT (COUNT(DISTINCT ?%3$s) AS ?n) WHERE {
967
                  GRAPH <%4$s> {
968
                    %5$s
969
                  }
970
                }
971
                """, NPA.NAMESPACE, GEN.NAMESPACE, varName, graph, wherePattern);
972
        try (RepositoryConnection conn = TripleStore.get().getRepoConnection(SPACES_REPO);
×
973
             TupleQueryResult r = conn.prepareTupleQuery(QueryLanguage.SPARQL, query).evaluate()) {
×
974
            if (!r.hasNext()) return 0;
×
975
            return Long.parseLong(r.next().getBinding("n").getValue().stringValue());
×
976
        } catch (Exception ex) {
×
977
            logger.warn("AuthorityResolver: countDistinctSubjects on {} failed: {}",
×
978
                    graph, ex.toString());
×
979
            return 0;
×
980
        }
981
    }
982

983
    // ---------------- SPARQL templates ----------------
984

985
    /**
986
     * Reusable invalidation filter on a bound nanopub-IRI variable. Pass the bare
987
     * variable name (no leading {@code ?}); e.g. {@code invalidationFilter("np")}
988
     * produces an outer-scoped {@code FILTER NOT EXISTS { GRAPH npa:graph
989
     * { ?_inv_np npx:invalidates ?np . } }}.
990
     *
991
     * <p>Joins on the raw {@code npx:invalidates} triple in {@code npa:graph},
992
     * which {@link com.knowledgepixels.query.NanopubLoader} writes into the
993
     * spaces repo from two complementary directions, making the filter symmetric
994
     * in load order:
995
     * <ul>
996
     *   <li>At the invalidator's own load: the loader's space-repo trigger fires
997
     *       whenever the nanopub has either its own space-relevant extractions
998
     *       OR an {@code npx:invalidates}/{@code npx:retracts}/{@code npx:supersedes}
999
     *       triple, so a pure-retraction nanopub still lands its raw triple plus
1000
     *       {@code npa:hasLoadNumber} stamp in {@code npa:graph}.</li>
1001
     *   <li>At the invalidated target's load (when the invalidator landed
1002
     *       earlier): {@code NanopubLoader.getInvalidatingStatements} reads the
1003
     *       triple back from the meta repo and mirrors it into the target's own
1004
     *       write to the spaces repo.</li>
1005
     * </ul>
1006
     *
1007
     * <p>The earlier shape joined on a structured {@code npa:Invalidation} entry
1008
     * in {@code npa:spacesGraph} that was only emitted on the invalidator's side
1009
     * AND only when the invalidated target's meta had already loaded, leaving a
1010
     * window where a superseding nanopub loaded before its target produced no
1011
     * entry and the stale row was never filtered out (see also the matching
1012
     * change in the tier-specific {@code *InvalidationCheckWhere}/{@code
1013
     * *InvalidationDelete} templates below).
1014
     *
1015
     * <p>Important: this filter must be placed OUTSIDE the surrounding
1016
     * {@code GRAPH npa:spacesGraph { ... }} block, not nested inside it. When
1017
     * nested, RDF4J's planner couples the FILTER NOT EXISTS evaluation into the
1018
     * join order (per-row scan multiplied by the candidate set), which we
1019
     * measured turning a 39ms query into a 60s+ timeout on the live observer-tier
1020
     * data. Outside the GRAPH block, the planner defers the filter until
1021
     * {@code ?np}/{@code ?rdNp} are bound and does a targeted index lookup.
1022
     *
1023
     * <p>Variable names must match {@code [A-Za-z0-9_]+} per SPARQL grammar —
1024
     * embedding a {@code ?} inside {@code ?_inv_?np} would yield a parse error.
1025
     */
1026
    private static String invalidationFilter(String bareVarName) {
1027
        return "FILTER NOT EXISTS { GRAPH <" + NPA.GRAPH + "> {"
30✔
1028
                + " ?_inv_" + bareVarName
1029
                + " <" + NPX.INVALIDATES + "> ?" + bareVarName + " . "
1030
                + samePublisherClause("_inv_" + bareVarName, bareVarName)
6✔
1031
                + " } }";
1032
    }
1033

1034
    /**
1035
     * SPARQL triple pair (placed inside a {@code GRAPH npa:graph { ... }} block)
1036
     * requiring the invalidating nanopub and its target to share a signing public
1037
     * key — the self-retraction authority gate for issue #112. Without it, the
1038
     * materializer honors {@code npx:invalidates}/{@code retracts}/{@code supersedes}
1039
     * from <em>any</em> validly-signed nanopub, so any agent can erase another
1040
     * space's materialized state (griefing/DoS of the view — fail-closed, no
1041
     * privilege escalation, but real). Additions are already admin-gated; this is
1042
     * the symmetric gate on removals.
1043
     *
1044
     * <p>Both {@code npa:hasValidSignatureForPublicKeyHash} triples live in
1045
     * {@code npa:graph} of the spaces repo: the target via its own space-load, the
1046
     * invalidator via the symmetric retractor propagation in
1047
     * {@link com.knowledgepixels.query.NanopubLoader} (forward {@code
1048
     * loadInvalidateStatements} + reverse {@code loadInvalidatorIntoSpacesRepo}),
1049
     * so the join is populated regardless of load order.
1050
     *
1051
     * <p>"Same pubkey" is intentionally stricter than "same agent": a retraction
1052
     * signed by a different key the author owns (key rotation) is not honored, and
1053
     * cross-admin supersession is out of scope here (would need an admin-authority
1054
     * arm). The pubkey-bridge variable is suffixed with {@code targetVar} so two
1055
     * filters in one query (e.g. on {@code ?np} and {@code ?rdNp}) don't collide.
1056
     *
1057
     * @param invVar    invalidator nanopub variable name (no leading {@code ?})
1058
     * @param targetVar invalidated-target nanopub variable name (no leading {@code ?})
1059
     */
1060
    private static String samePublisherClause(String invVar, String targetVar) {
1061
        String pk = "?_invpk_" + targetVar;
9✔
1062
        return "?" + invVar + " <" + NPA.HAS_VALID_SIGNATURE_FOR_PUBLIC_KEY_HASH + "> " + pk + " . "
30✔
1063
                + "?" + targetVar + " <" + NPA.HAS_VALID_SIGNATURE_FOR_PUBLIC_KEY_HASH + "> " + pk + " .";
1064
    }
1065

1066
    /**
1067
     * Admin tier: seed from {@code npadef:...hasRootAdmin} (trusted by construction)
1068
     * plus closed-over admin grants; insert any {@code gen:RoleInstantiation} with
1069
     * {@code npa:inverseProperty gen:hasAdmin} whose publisher (resolved via mirrored
1070
     * trust-approved AccountState) is already in the admin set.
1071
     *
1072
     * <p>The seed is gated by {@link #spaceRefAliveFilter} (not the per-nanopub
1073
     * {@code invalidationFilter("defNp")}): the {@code hasRootAdmin} seed is anchored
1074
     * to the root NPID, which is the immutable space-ref identity, so superseding the
1075
     * root <em>nanopub</em> with a continuation revision must not strip the seed —
1076
     * only retracting every definition of the ref removes it. See issue #110.
1077
     */
1078
    static String adminTierUpdate(IRI graph, long lastProcessed) {
1079
        // Order tuned for RDF4J's evaluator:
1080
        //   1. Anchor on the small (seed UNION closed-over) set to bind ?publisher
1081
        //      and ?space cheaply.
1082
        //   2. Resolve ?pkh from the mirrored AccountState row (?publisher bound).
1083
        //   3. Probe instantiations using the now-bound (?space, ?pkh) — targeted
1084
        //      lookup, not a full RoleInstantiation scan.
1085
        //   4. Load-number filter on bound ?np.
1086
        //   5. Dedup at the end.
1087
        // Authority is keyed on the space *ref* (npa:forSpaceRef), not the bare Space
1088
        // IRI: two refs that share an IRI but have different roots are independent
1089
        // domains (see doc/design-spaceref-isolation.md). The instantiation evidence in
1090
        // the extraction graph is IRI-keyed (a gen:hasAdmin nanopub names the bare IRI),
1091
        // so we project it per-ref by joining each instantiation naming ?space to the
1092
        // admin rows of every ref of ?space whose admin set contains the publisher. The
1093
        // inserted subject is minted per (?ri, ?spaceRef) so one instantiation validating
1094
        // into N refs yields N distinct rows. TRANSITIONAL-DUAL-EMIT (Phase 4: remove):
1095
        // forSpace is still emitted alongside forSpaceRef so the not-yet-migrated
1096
        // downstream tiers / pre-ref read queries keep functioning on a mixed-version
1097
        // fleet; it is dropped once everything keys on forSpaceRef.
1098
        return """
69✔
1099
                PREFIX npa:  <%1$s>
1100
                PREFIX gen:  <%2$s>
1101
                INSERT { GRAPH <%3$s> {
1102
                  ?sri a gen:RoleInstantiation ;
1103
                       npa:forSpaceRef ?spaceRef ;
1104
                       npa:forSpace ?space ;
1105
                       npa:inverseProperty gen:hasAdmin ;
1106
                       # Stamp the admin tier so consumers read tier uniformly across all
1107
                       # RoleInstantiations (?ri npa:hasRoleType ?tier) with no admin
1108
                       # special-case — matching the non-admin path (issue #125, #127).
1109
                       npa:hasRoleType gen:AdminRole ;
1110
                       npa:forAgent ?agent ;
1111
                       npa:viaNanopub ?np .
1112
                } }
1113
                WHERE {
1114
                  # 1. Anchor: who is already an admin of which space ref?
1115
                  {
1116
                    # Seed branch: root-admin of a space ref that is still alive
1117
                    # (has at least one non-invalidated definition). NOT filtered on
1118
                    # ?def's own invalidation — superseding the root nanopub with a
1119
                    # continuation revision must keep the seed; only a fully-retracted
1120
                    # ref drops it (issue #110).
1121
                    GRAPH <%4$s> {
1122
                      ?def a npa:SpaceDefinition ;
1123
                           npa:forSpaceRef  ?spaceRef ;
1124
                           npa:hasRootAdmin ?publisher .
1125
                      ?spaceRef npa:spaceIri ?space .
1126
                    }
1127
                    %7$s
1128
                  }
1129
                  UNION
1130
                  {
1131
                    # Closed-over branch: an existing admin of this ref. Recurse on the
1132
                    # ref, then resolve its bare IRI to probe the IRI-keyed instantiation.
1133
                    GRAPH <%3$s> {
1134
                      ?prev a gen:RoleInstantiation ;
1135
                            npa:forSpaceRef     ?spaceRef ;
1136
                            npa:inverseProperty gen:hasAdmin ;
1137
                            npa:forAgent        ?publisher .
1138
                    }
1139
                    GRAPH <%4$s> {
1140
                      ?spaceRef npa:spaceIri ?space .
1141
                    }
1142
                  }
1143
                  # 2. Mirror: resolve ?publisher → ?pkh via the trust-approved row.
1144
                  GRAPH <%3$s> {
1145
                    ?acct a npa:AccountState ;
1146
                          npa:agent  ?publisher ;
1147
                          npa:pubkey ?pkh .
1148
                  }
1149
                  # 3. Targeted instantiation lookup by space + pubkey (IRI-keyed).
1150
                  GRAPH <%4$s> {
1151
                    ?ri a gen:RoleInstantiation ;
1152
                        npa:forSpace        ?space ;
1153
                        npa:inverseProperty gen:hasAdmin ;
1154
                        npa:forAgent        ?agent ;
1155
                        npa:pubkeyHash      ?pkh ;
1156
                        npa:viaNanopub      ?np .
1157
                    # Candidate grant timestamp for the admin-revocation latest-wins (#129).
1158
                    OPTIONAL { ?ri <http://purl.org/dc/terms/created> ?candCreatedRaw . }
1159
                  }
1160
                  BIND(COALESCE(?candCreatedRaw, %9$s) AS ?candCreated)
1161
                  # 3a. Mint the per-ref state subject: (?ri, ?spaceRef) → ?sri.
1162
                  BIND(IRI(CONCAT(STR(?ri), "__", ENCODE_FOR_URI(STR(?spaceRef)))) AS ?sri)
1163
                  %6$s
1164
                  # 4. Load-number filter on bound ?np.
1165
                  GRAPH <%8$s> {
1166
                    ?np npa:hasLoadNumber ?ln .
1167
                    FILTER (?ln > %5$d)
1168
                  }
1169
                  # 4a. Admin-revocation latest-wins (issue #129): suppress if a newer
1170
                  #     authorized admin revocation shadows (ref, agent) — unless ?agent is a
1171
                  #     root admin (constitutional exemption, overrides self-leave).
1172
                  %10$s
1173
                  # 5. Dedup last — keyed on (ref, agent).
1174
                  FILTER NOT EXISTS { GRAPH <%3$s> {
1175
                    ?existing a gen:RoleInstantiation ;
1176
                              npa:forSpaceRef ?spaceRef ;
1177
                              npa:forAgent ?agent ;
1178
                              npa:inverseProperty gen:hasAdmin .
1179
                  } }
1180
                }
1181
                """.formatted(
3✔
1182
                NPA.NAMESPACE,
1183
                GEN.NAMESPACE,
1184
                graph,
1185
                SpacesVocab.SPACES_GRAPH,
1186
                lastProcessed,
15✔
1187
                invalidationFilter("np"),
12✔
1188
                spaceRefAliveFilter(),
39✔
1189
                NPA.GRAPH,
1190
                EPOCH_DT,
1191
                adminRevocationSuppressionFilter(graph));
6✔
1192
    }
1193

1194
    /**
1195
     * Seed-survival filter for the admin tier (issue #110). The {@code hasRootAdmin}
1196
     * seed is anchored to the root NPID, which is the immutable space-ref identity, so
1197
     * it must survive supersession of the root <em>nanopub</em> by a continuation
1198
     * revision (a later definition re-roots to the same ref via
1199
     * {@code gen:hasRootDefinition} and so carries no {@code hasRootAdmin} of its own).
1200
     * The previous {@code invalidationFilter("defNp")} dropped the seed the moment the
1201
     * root revision was superseded, leaving the whole admin closure — and everything
1202
     * cascading from it — unmaterialized for any space whose definition had ever been
1203
     * updated.
1204
     *
1205
     * <p>Expressed positively: the seed survives iff the space ref still has at least
1206
     * one non-invalidated {@link SpacesVocab#SPACE_DEFINITION}. A fully-retracted ref
1207
     * (every definition invalidated) has no live definition, so the {@code FILTER
1208
     * EXISTS} fails and the seed correctly disappears. Anchored on the already-bound
1209
     * {@code ?spaceRef}, so it's a targeted lookup over that ref's (few) definitions.
1210
     */
1211
    private static String spaceRefAliveFilter() {
1212
        return """
33✔
1213
                FILTER EXISTS {
1214
                  GRAPH <%1$s> {
1215
                    ?liveDef a npa:SpaceDefinition ;
1216
                             npa:forSpaceRef ?spaceRef ;
1217
                             npa:viaNanopub  ?liveNp .
1218
                  }
1219
                  %2$s
1220
                }
1221
                """.formatted(SpacesVocab.SPACES_GRAPH, invalidationFilter("liveNp"));
9✔
1222
    }
1223

1224
    /**
1225
     * {@code gen:hasRole} attachment validation: an attachment is validated iff its
1226
     * publisher is already a validated admin of the target space. Adds
1227
     * {@code gen:RoleAssignment} rows to the space-state graph.
1228
     */
1229
    static String attachmentValidationUpdate(IRI graph, long lastProcessed) {
1230
        // Ref-keyed (see doc/design-spaceref-isolation.md). The attachment names a bare
1231
        // Space IRI; it is validated per-ref for every ref of that IRI whose admin set
1232
        // contains the publisher (direct), or — when the named IRI is an owl:sameAs alias
1233
        // — for the canonical ref it maps to (issue #113). ?targetRef is the ref the
1234
        // RoleAssignment attaches to; the inserted subject is minted per (?ra, ?targetRef)
1235
        // so one attachment validating into N refs yields N distinct rows.
1236
        // TRANSITIONAL-DUAL-EMIT (Phase 4: remove): forSpace (the attached IRI, possibly an
1237
        // alias) is kept so the non-admin tier can probe the IRI-keyed instantiations
1238
        // naming it, and so pre-ref read queries keep functioning on a mixed-version fleet.
1239
        return """
69✔
1240
                PREFIX npa:  <%1$s>
1241
                PREFIX gen:  <%2$s>
1242
                INSERT { GRAPH <%3$s> {
1243
                  ?ra2 a gen:RoleAssignment ;
1244
                       npa:forSpaceRef ?targetRef ;
1245
                       npa:forSpace ?space ;
1246
                       gen:hasRole  ?role ;
1247
                       npa:viaNanopub ?np .
1248
                } }
1249
                WHERE {
1250
                  GRAPH <%4$s> {
1251
                    ?ra a gen:RoleAssignment ;
1252
                        npa:forSpace ?space ;
1253
                        gen:hasRole  ?role ;
1254
                        npa:pubkeyHash ?pkh ;
1255
                        npa:viaNanopub ?np .
1256
                    # Attachment timestamp for the detachment latest-wins (issue #129).
1257
                    OPTIONAL { ?ra <http://purl.org/dc/terms/created> ?attCreatedRaw . }
1258
                  }
1259
                  BIND(COALESCE(?attCreatedRaw, %8$s) AS ?attCreated)
1260
                  GRAPH <%7$s> {
1261
                    ?np npa:hasLoadNumber ?ln .
1262
                    FILTER (?ln > %5$d)
1263
                  }
1264
                  GRAPH <%3$s> {
1265
                    ?acct a npa:AccountState ;
1266
                          npa:agent  ?publisher ;
1267
                          npa:pubkey ?pkh .
1268
                  }
1269
                  # Per-ref admin gate. ?targetRef = a ref of ?space the publisher admins
1270
                  # (direct), or the canonical ref ?space is an owl:sameAs alias of.
1271
                  {
1272
                    GRAPH <%4$s> { ?targetRef npa:spaceIri ?space . }
1273
                    GRAPH <%3$s> {
1274
                      ?adminRI a gen:RoleInstantiation ;
1275
                               npa:forSpaceRef ?targetRef ;
1276
                               npa:inverseProperty gen:hasAdmin ;
1277
                               npa:forAgent ?publisher .
1278
                    }
1279
                  }
1280
                  UNION
1281
                  {
1282
                    GRAPH <%3$s> {
1283
                      ?space npa:sameAsSpace ?targetRef .
1284
                      ?adminRI a gen:RoleInstantiation ;
1285
                               npa:forSpaceRef ?targetRef ;
1286
                               npa:inverseProperty gen:hasAdmin ;
1287
                               npa:forAgent ?publisher .
1288
                    }
1289
                  }
1290
                  BIND(IRI(CONCAT(STR(?ra), "__", ENCODE_FOR_URI(STR(?targetRef)))) AS ?ra2)
1291
                  %6$s
1292
                  # Detachment latest-wins (issue #129): suppress if a newer admin-authored
1293
                  # gen:detachedRole out-ranks this (ref, role) attachment.
1294
                  %9$s
1295
                  FILTER NOT EXISTS { GRAPH <%3$s> {
1296
                    ?existing a gen:RoleAssignment ;
1297
                              npa:forSpaceRef ?targetRef ;
1298
                              gen:hasRole  ?role .
1299
                  } }
1300
                }
1301
                """.formatted(
3✔
1302
                NPA.NAMESPACE,
1303
                GEN.NAMESPACE,
1304
                graph,
1305
                SpacesVocab.SPACES_GRAPH,
1306
                lastProcessed,
15✔
1307
                invalidationFilter("np"),
45✔
1308
                NPA.GRAPH,
1309
                EPOCH_DT,
1310
                roleDetachmentSuppressionFilter(graph, "attCreated", "ra"));
6✔
1311
    }
1312

1313
    /**
1314
     * Preset-bundled role materialization (Nanodash issue #302). For each active,
1315
     * admin-authored {@code gen:PresetAssignment} targeting a {@code gen:Space}, inserts
1316
     * one {@code gen:RoleAssignment} per role the preset bundles — exactly as if
1317
     * {@code <space> gen:hasRole <role>} had been published by the assignment's publisher.
1318
     * The materialized rows carry {@code npa:derivedFromPreset} (the assignment nanopub)
1319
     * so the deactivation delete and read-side marking can scope to them without touching
1320
     * directly-published attachments. See {@code doc/design-preset-role-materialization.md}.
1321
     *
1322
     * <p>Activation is resolved by an <b>authorization-scoped latest-wins</b> over the
1323
     * {@code (preset, resource)} pair, NOT {@code npx:invalidates} (§3): the candidate set
1324
     * for the {@code MAX(dct:created)} comparison is restricted to assignments whose
1325
     * publisher is also a validated admin of the target ref, so an unauthorized key's newer
1326
     * assignment cannot shadow an admin's activation (the #113-class anti-hijack rule).
1327
     */
1328
    static String presetAttachmentValidationUpdate(IRI graph, long lastProcessed) {
1329
        // Ref-keyed like attachmentValidationUpdate: the assignment names a bare resource
1330
        // IRI; it is validated per-ref for every Space ref of that IRI whose admin set
1331
        // contains the publisher. The inserted subject is minted per (assignment, ref, role)
1332
        // — one assignment fans out to N roles and N refs. Non-Space targets resolve no
1333
        // ?targetRef and so insert nothing (correct no-op; maintained-resource / individual
1334
        // targets are future work, see design doc §2). TRANSITIONAL-DUAL-EMIT (Phase 4:
1335
        // remove): forSpace kept alongside forSpaceRef so the non-admin tiers can probe the
1336
        // IRI-keyed instantiations and pre-ref read queries keep functioning.
1337
        return """
69✔
1338
                PREFIX npa:  <%1$s>
1339
                PREFIX gen:  <%2$s>
1340
                INSERT { GRAPH <%3$s> {
1341
                  ?ra2 a gen:RoleAssignment ;
1342
                       npa:forSpaceRef ?targetRef ;
1343
                       npa:forSpace    ?resource ;
1344
                       gen:hasRole     ?role ;
1345
                       npa:viaNanopub  ?assignNp ;
1346
                       npa:derivedFromPreset ?assignNp .
1347
                } }
1348
                WHERE {
1349
                  # 1. Anchor: active preset assignments in the extraction graph.
1350
                  GRAPH <%4$s> {
1351
                    ?pa a npa:PresetAssignment ;
1352
                        npa:ofPreset    ?preset ;
1353
                        npa:forResource ?resource ;
1354
                        npa:isActivated true ;
1355
                        npa:pubkeyHash  ?pkh ;
1356
                        npa:viaNanopub  ?assignNp ;
1357
                        <http://purl.org/dc/terms/created> ?created .
1358
                  }
1359
                  # 2. Load-number filter on the assignment nanopub.
1360
                  GRAPH <%7$s> {
1361
                    ?assignNp npa:hasLoadNumber ?ln .
1362
                    FILTER (?ln > %5$d)
1363
                  }
1364
                  # 3. Resolve publisher pkh -> agent via the mirrored trust-approved row.
1365
                  GRAPH <%3$s> {
1366
                    ?acct a npa:AccountState ;
1367
                          npa:agent  ?publisher ;
1368
                          npa:pubkey ?pkh .
1369
                  }
1370
                  # 4. Target must be a Space ref the publisher admins — direct, or the
1371
                  #    canonical ref ?resource is an owl:sameAs alias of (issue #113 parity
1372
                  #    with attachmentValidationUpdate, so a preset assigned against an alias
1373
                  #    IRI still materializes against the canonical ref).
1374
                  {
1375
                    GRAPH <%4$s> { ?targetRef npa:spaceIri ?resource . }
1376
                    GRAPH <%3$s> {
1377
                      ?adminRI a gen:RoleInstantiation ;
1378
                               npa:forSpaceRef ?targetRef ;
1379
                               npa:inverseProperty gen:hasAdmin ;
1380
                               npa:forAgent ?publisher .
1381
                    }
1382
                  }
1383
                  UNION
1384
                  {
1385
                    GRAPH <%3$s> {
1386
                      ?resource npa:sameAsSpace ?targetRef .
1387
                      ?adminRI a gen:RoleInstantiation ;
1388
                               npa:forSpaceRef ?targetRef ;
1389
                               npa:inverseProperty gen:hasAdmin ;
1390
                               npa:forAgent ?publisher .
1391
                    }
1392
                  }
1393
                  # 5. Resolve the assignment's referenced preset IRI (node or kind) to its
1394
                  #    canonical kind, mirroring how Nanodash views key on dct:isVersionOf
1395
                  #    (ViewDisplay.getViewKindIri). Every declaration carries npa:ofPreset for
1396
                  #    both its node IRI and kind, so either reference maps to the same ?kind.
1397
                  GRAPH <%4$s> {
1398
                    ?pdMap a npa:PresetDeclaration ;
1399
                           npa:ofPreset   ?preset ;
1400
                           npa:presetKind ?kind .
1401
                  }
1402
                  # 5a. Roles come from the LATEST live declaration of that kind, restricted to
1403
                  #     Space-targeted presets — so a superseded preset version's roles never leak
1404
                  #     (the per-view-kind latest-wins, ported to materialization).
1405
                  GRAPH <%4$s> {
1406
                    ?pd a npa:PresetDeclaration ;
1407
                        npa:presetKind           ?kind ;
1408
                        npa:presetRole           ?role ;
1409
                        npa:appliesToInstancesOf gen:Space ;
1410
                        npa:viaNanopub           ?pdNp ;
1411
                        <http://purl.org/dc/terms/created> ?pdCreated .
1412
                  }
1413
                  # 5b. Latest-declaration-per-kind: reject if a newer LIVE declaration of the
1414
                  #     same kind exists (tiebreak on subject IRI for equal timestamps).
1415
                  FILTER NOT EXISTS {
1416
                    GRAPH <%4$s> {
1417
                      ?pdNewer a npa:PresetDeclaration ;
1418
                               npa:presetKind ?kind ;
1419
                               npa:viaNanopub ?pdNpNewer ;
1420
                               <http://purl.org/dc/terms/created> ?pdCreatedNewer .
1421
                      FILTER (?pdCreatedNewer > ?pdCreated
1422
                              || (?pdCreatedNewer = ?pdCreated && STR(?pdNewer) > STR(?pd)))
1423
                    }
1424
                    %8$s
1425
                  }
1426
                  # 5c. The chosen declaration must itself be live (not superseded/retracted).
1427
                  %9$s
1428
                  # 6. Mint the per (assignment, ref, role) subject.
1429
                  BIND(IRI(CONCAT(STR(?pa), "__", ENCODE_FOR_URI(STR(?targetRef)),
1430
                                  "__", ENCODE_FOR_URI(STR(?role)))) AS ?ra2)
1431
                  # 7. Authorization-scoped latest-wins (anti-hijack, design doc §3): reject
1432
                  #    if a newer same-(preset,resource) assignment exists whose publisher is
1433
                  #    ALSO a validated admin of ?targetRef. Filtering the shadowing candidate
1434
                  #    to admin-authored rows BEFORE taking the latest is what stops an
1435
                  #    unauthorized key from suppressing an admin's activation. Placed after
1436
                  #    the main vars are bound so the planner defers it (RDF4J quirk).
1437
                  FILTER NOT EXISTS {
1438
                    GRAPH <%4$s> {
1439
                      ?paNewer a npa:PresetAssignment ;
1440
                               npa:ofPreset    ?preset ;
1441
                               npa:forResource ?resource ;
1442
                               npa:pubkeyHash  ?pkhNewer ;
1443
                               <http://purl.org/dc/terms/created> ?createdNewer .
1444
                      FILTER (?createdNewer > ?created
1445
                              || (?createdNewer = ?created && STR(?paNewer) > STR(?pa)))
1446
                    }
1447
                    GRAPH <%3$s> {
1448
                      ?acctNewer a npa:AccountState ;
1449
                                 npa:agent  ?publisherNewer ;
1450
                                 npa:pubkey ?pkhNewer .
1451
                      ?adminRINewer a gen:RoleInstantiation ;
1452
                                    npa:forSpaceRef ?targetRef ;
1453
                                    npa:inverseProperty gen:hasAdmin ;
1454
                                    npa:forAgent ?publisherNewer .
1455
                    }
1456
                  }
1457
                  # 8. Defensive: drop if the assignment nanopub itself was hard-retracted.
1458
                  %6$s
1459
                  # 8a. Detachment latest-wins (issue #129): suppress if a newer admin-authored
1460
                  #     gen:detachedRole out-ranks this preset-derived (ref, role) attachment.
1461
                  #     Non-sticky: a newer PresetAssignment (newer ?created) re-attaches.
1462
                  %10$s
1463
                  # 9. Dedup last — keyed on (ref, role).
1464
                  FILTER NOT EXISTS { GRAPH <%3$s> {
1465
                    ?existing a gen:RoleAssignment ;
1466
                              npa:forSpaceRef ?targetRef ;
1467
                              gen:hasRole ?role .
1468
                  } }
1469
                }
1470
                """.formatted(
3✔
1471
                NPA.NAMESPACE,
1472
                GEN.NAMESPACE,
1473
                graph,
1474
                SpacesVocab.SPACES_GRAPH,
1475
                lastProcessed,
15✔
1476
                invalidationFilter("assignNp"),
27✔
1477
                NPA.GRAPH,
1478
                invalidationFilter("pdNpNewer"),
15✔
1479
                invalidationFilter("pdNp"),
21✔
1480
                roleDetachmentSuppressionFilter(graph, "created", "pa"));
6✔
1481
    }
1482

1483
    /**
1484
     * Stamps a ref-scoped, admin-validated mirror of each {@code npa:PresetAssignment}
1485
     * into the state graph (issue #122). The publisher-agnostic extraction row
1486
     * ({@link SpacesExtractor#extractPresetAssignment}) is keyed only by
1487
     * {@code npa:forResource}, so a consumer listing a space's preset assignments by IRI
1488
     * sees the union across <em>all</em> refs claiming that IRI. This stamp adds
1489
     * {@code npa:forSpaceRef ?targetRef} so the "Assigned presets" listing is no longer
1490
     * merged across refs of the same IRI — the one remaining About-tab listing that still
1491
     * merged across refs (every other ref-scoped listing already has a {@code forSpaceRef}
1492
     * companion).
1493
     *
1494
     * <p>Faithful per-assignment mirror — deliberately <em>not</em> role-gated and
1495
     * <em>not</em> latest-wins-resolved, unlike {@link #presetAttachmentValidationUpdate}:
1496
     * <ul>
1497
     *   <li>No {@code npa:PresetDeclaration}/role join, so a preset that bundles only
1498
     *       <em>views</em> (no roles) is still listed.</li>
1499
     *   <li>Emits active <em>and</em> deactivated rows (carries {@code npa:isActivated})
1500
     *       so the listing can show state; a deactivation is just a newer admin-authored
1501
     *       row, so no {@code dct:created}-driven removal is needed here (contrast §4.4).</li>
1502
     *   <li>Latest-wins is deferred to the consumer query, which ranges only over these
1503
     *       admin-authored rows — so it is authorization-scoped for free (design §3): a
1504
     *       non-admin of the ref can never get a row stamped, so it cannot enter the
1505
     *       latest-wins race.</li>
1506
     * </ul>
1507
     *
1508
     * <p>Display-only leaf: nothing downstream derives from these rows (contrast the
1509
     * preset-derived {@code gen:RoleAssignment}), so the caller must <em>not</em> feed this
1510
     * tier's count into {@code structuralAdds}. The {@code npa:forSpaceRef} predicate also
1511
     * distinguishes a stamped row from the IRI-keyed extraction row (which never carries it),
1512
     * so {@link #presetAssignmentRefInvalidationDelete} can target exactly these rows.
1513
     * Reuses steps 1–4 of {@link #presetAttachmentValidationUpdate}; see
1514
     * doc/design-preset-role-materialization.md §3 and issue #122.
1515
     */
1516
    static String presetAssignmentRefStampUpdate(IRI graph, long lastProcessed) {
1517
        return """
69✔
1518
                PREFIX npa:  <%1$s>
1519
                PREFIX gen:  <%2$s>
1520
                INSERT { GRAPH <%3$s> {
1521
                  ?paRef a npa:PresetAssignment ;
1522
                         npa:ofPreset    ?preset ;
1523
                         npa:forResource ?resource ;
1524
                         npa:forSpaceRef ?targetRef ;
1525
                         npa:isActivated ?activated ;
1526
                         npa:viaNanopub  ?assignNp ;
1527
                         <http://purl.org/dc/terms/created> ?created .
1528
                } }
1529
                WHERE {
1530
                  # 1. Anchor: every assignment row (active or not) in the extraction graph.
1531
                  GRAPH <%4$s> {
1532
                    ?pa a npa:PresetAssignment ;
1533
                        npa:ofPreset    ?preset ;
1534
                        npa:forResource ?resource ;
1535
                        npa:isActivated ?activated ;
1536
                        npa:pubkeyHash  ?pkh ;
1537
                        npa:viaNanopub  ?assignNp ;
1538
                        <http://purl.org/dc/terms/created> ?created .
1539
                  }
1540
                  # 2. Load-number filter on the assignment nanopub (delta window).
1541
                  GRAPH <%6$s> {
1542
                    ?assignNp npa:hasLoadNumber ?ln .
1543
                    FILTER (?ln > %5$d)
1544
                  }
1545
                  # 3. Resolve publisher pkh -> agent via the mirrored trust-approved row.
1546
                  GRAPH <%3$s> {
1547
                    ?acct a npa:AccountState ;
1548
                          npa:agent  ?publisher ;
1549
                          npa:pubkey ?pkh .
1550
                  }
1551
                  # 4. Target must be a Space ref the publisher admins. ?targetRef = that ref;
1552
                  #    fan-out to N refs the publisher admins (per-ref isolation, consistent
1553
                  #    with the role materializer and design-spaceref-isolation.md). Direct,
1554
                  #    or the canonical ref ?resource is an owl:sameAs alias of (issue #113),
1555
                  #    so an assignment naming an alias is still listed under the canonical ref.
1556
                  {
1557
                    GRAPH <%4$s> { ?targetRef npa:spaceIri ?resource . }
1558
                    GRAPH <%3$s> {
1559
                      ?adminRI a gen:RoleInstantiation ;
1560
                               npa:forSpaceRef ?targetRef ;
1561
                               npa:inverseProperty gen:hasAdmin ;
1562
                               npa:forAgent ?publisher .
1563
                    }
1564
                  }
1565
                  UNION
1566
                  {
1567
                    GRAPH <%3$s> {
1568
                      ?resource npa:sameAsSpace ?targetRef .
1569
                      ?adminRI a gen:RoleInstantiation ;
1570
                               npa:forSpaceRef ?targetRef ;
1571
                               npa:inverseProperty gen:hasAdmin ;
1572
                               npa:forAgent ?publisher .
1573
                    }
1574
                  }
1575
                  # 5. Defensive: drop if the assignment nanopub itself was hard-retracted.
1576
                  %7$s
1577
                  # 6. Mint per (assignment, ref); dedup on the bound subject. No latest-wins
1578
                  #    here — a deactivation is just a newer admin-authored row, and the
1579
                  #    consumer resolves latest dct:created per (preset,resource) over these
1580
                  #    admin-authored rows (so the resolution is authorization-scoped).
1581
                  BIND(IRI(CONCAT(STR(?pa), "__", ENCODE_FOR_URI(STR(?targetRef)))) AS ?paRef)
1582
                  FILTER NOT EXISTS { GRAPH <%3$s> { ?paRef a npa:PresetAssignment . } }
1583
                }
1584
                """.formatted(
3✔
1585
                NPA.NAMESPACE,
1586
                GEN.NAMESPACE,
1587
                graph,
1588
                SpacesVocab.SPACES_GRAPH,
1589
                lastProcessed,
27✔
1590
                NPA.GRAPH,
1591
                invalidationFilter("assignNp"));
6✔
1592
    }
1593

1594
    /**
1595
     * Non-admin tier publisher constraints (inserted as a SPARQL sub-pattern).
1596
     * Each constraint owns the AccountState (pkh → agent) lookup so the join
1597
     * variable is bound through a targeted pattern. The observer-self variant
1598
     * binds {@code npa:agent ?agent} directly — no separate {@code ?publisher}
1599
     * variable, no post-join equality filter — which lets the planner anchor
1600
     * the AccountState lookup on the already-bound {@code ?agent} instead of
1601
     * enumerating all approved publishers and filtering at the end.
1602
     */
1603
    static final String PUBLISHER_IS_ADMIN = """
1604
            ?acct a npa:AccountState ;
1605
                  npa:pubkey ?pkh ;
1606
                  npa:agent  ?publisher .
1607
            # Admin of the assignment's ref. The ref already resolves alias →
1608
            # canonical (the attachment tier bound ?spaceRef through the owl:sameAs
1609
            # alias edge for aliased IRIs, issue #113), so no alias arm is needed here.
1610
            ?adminRI a gen:RoleInstantiation ;
1611
                     npa:forSpaceRef ?spaceRef ;
1612
                     npa:inverseProperty gen:hasAdmin ;
1613
                     npa:forAgent ?publisher .
1614
            """;
1615

1616
    /** Observer self-evidence: the assignee's own pubkey signed the instantiation. */
1617
    static final String PUBLISHER_IS_SELF = """
1618
            ?acct a npa:AccountState ;
1619
                  npa:pubkey ?pkh ;
1620
                  npa:agent  ?agent .
1621
            """;
1622

1623
    /**
1624
     * Maintainer / Member / Observer tier INSERT. Same shape: find an instantiation
1625
     * whose predicate matches a RoleDeclaration of the given tier attached to the
1626
     * target space, and whose publisher passes the tier-specific constraint.
1627
     */
1628
    static String nonAdminTierUpdate(IRI graph, long lastProcessed,
1629
                                     IRI tierClass, String publisherConstraint) {
1630
        // Order tuned for RDF4J's evaluator (which executes BGPs roughly in order).
1631
        // The crucial choice is the *anchor*: instantiation-first plans send the
1632
        // planner exploring the full ~thousands of candidate RIs and only filter
1633
        // by tier at the very end. Attachment-first anchors on the small set of
1634
        // gen:RoleAssignment rows already validated in this space-state graph
1635
        // (~hundreds, often zero) and walks outward by bound (?role, ?space).
1636
        //
1637
        //   1. Anchor on RoleAssignments in this space-state graph (small).
1638
        //   1a. Resolve the IRIs that denote the assignment's ref — its canonical
1639
        //      IRI plus any validated owl:sameAs aliases — so an instantiation that
1640
        //      names an alias of the space still matches (issue #113). Bound here so
1641
        //      the instantiation lookup below stays anchored by ?instSpace.
1642
        //   2. Match the tier-pinned RoleDeclaration by ?role.
1643
        //   3. Pair role-decl direction to instantiation direction in one UNION
1644
        //      so only (reg, reg)/(inv, inv) combos are explored.
1645
        //   4. Targeted instantiation lookup — (?instSpace, ?pred) are bound.
1646
        //   5. Publisher constraint (incl. AccountState resolution).
1647
        //   6. Load-number filter on bound ?np.
1648
        //   7. Dedup at the end.
1649
        return """
69✔
1650
                PREFIX npa:  <%1$s>
1651
                PREFIX gen:  <%2$s>
1652
                INSERT { GRAPH <%3$s> {
1653
                  ?ri2 a gen:RoleInstantiation ;
1654
                       npa:forSpaceRef ?spaceRef ;
1655
                       # TRANSITIONAL-DUAL-EMIT (Phase 4: remove): forSpace alongside
1656
                       # forSpaceRef so pre-ref read queries (e.g. get-space-members) keep
1657
                       # functioning on a mixed-version fleet; downstream tiers key on the ref.
1658
                       npa:forSpace ?space ;
1659
                       npa:forAgent ?agent ;
1660
                       ?dirPred ?pred ;
1661
                       # Persist the tier and role IRI that are already bound at this point —
1662
                       # the loop's tierClass arg (%7$s) and the anchoring attachment's ?role
1663
                       # (step 1) — so ref-scoped consumers key on identity rather than
1664
                       # re-deriving the tier from the bare predicate against GLOBAL
1665
                       # RoleDeclarations. The bare-predicate re-derivation bleeds tiers
1666
                       # across spaces that declare the same predicate at different tiers
1667
                       # (issue #125): consumers should match ?ri2 npa:hasRoleType <tier>
1668
                       # / gen:hasRole ?role, exactly as the *-roles-ref queries do.
1669
                       npa:hasRoleType <%7$s> ;
1670
                       gen:hasRole ?role ;
1671
                       npa:viaNanopub ?np .
1672
                } }
1673
                WHERE {
1674
                  # 1. Anchor: validated attachments in this space-state graph (ref-keyed).
1675
                  GRAPH <%3$s> {
1676
                    ?ra a gen:RoleAssignment ;
1677
                        gen:hasRole     ?role ;
1678
                        npa:forSpaceRef ?spaceRef ;
1679
                        npa:forSpace    ?space .
1680
                  }
1681
                  # 1a. The IRIs that denote this ref: its canonical IRI, plus any validated
1682
                  #     owl:sameAs aliases of it (issue #113) — so an instantiation naming an
1683
                  #     alias of the space still materializes here. Bound BEFORE the
1684
                  #     instantiation BGP so that lookup stays anchored by ?instSpace (planner
1685
                  #     note above); ?spaceRef is already bound, so each arm is a targeted
1686
                  #     lookup yielding a tiny IRI set. The alias arm only follows admin-
1687
                  #     validated npa:sameAsSpace edges, so it grants no authority the admin
1688
                  #     tier would not (anti-hijack is enforced upstream, not relaxed here).
1689
                  {
1690
                    GRAPH <%4$s> { ?spaceRef npa:spaceIri ?instSpace . }
1691
                  }
1692
                  UNION
1693
                  {
1694
                    GRAPH <%3$s> { ?instSpace npa:sameAsSpace ?spaceRef . }
1695
                  }
1696
                  # 2. Tier-pinned RoleDeclaration (?role bound from the attachment). Its
1697
                  #    nanopub's invalidation is intentionally NOT consulted (see step 7), so
1698
                  #    no ?rdNp binding is needed.
1699
                  GRAPH <%4$s> {
1700
                    ?rd a npa:RoleDeclaration ;
1701
                        npa:hasRoleType <%7$s> ;
1702
                        npa:role        ?role .
1703
                    # 3. Pair role-decl direction to the instantiation in one UNION so only
1704
                    #    matching combos are explored, binding (?instSpace, ?agent) per arm.
1705
                    #    ?dirPred carries the resolved direction so the materialized row
1706
                    #    records the role property (read by get-space-members and
1707
                    #    publisherIsTieredRole) — identical shape whichever arm matched.
1708
                    #
1709
                    #    The first two arms handle instantiations the extractor already
1710
                    #    classified (npa:regularProperty / npa:inverseProperty). The last two
1711
                    #    resolve a custom predicate the extractor left neutral (npa:rolePredicate
1712
                    #    with raw npa:bindingSubject / npa:bindingObject): the role declaration
1713
                    #    supplies the direction, which fixes which raw endpoint is the space vs
1714
                    #    the agent. INVERSE = <space> pred <agent>; REGULAR = <agent> pred <space>.
1715
                    {
1716
                      ?rd gen:hasRegularProperty ?pred .
1717
                      ?ri npa:regularProperty ?pred ;
1718
                          npa:forSpace ?instSpace ;
1719
                          npa:forAgent ?agent .
1720
                      BIND(npa:regularProperty AS ?dirPred)
1721
                    }
1722
                    UNION
1723
                    {
1724
                      ?rd gen:hasInverseProperty ?pred .
1725
                      ?ri npa:inverseProperty ?pred ;
1726
                          npa:forSpace ?instSpace ;
1727
                          npa:forAgent ?agent .
1728
                      BIND(npa:inverseProperty AS ?dirPred)
1729
                    }
1730
                    UNION
1731
                    {
1732
                      ?rd gen:hasInverseProperty ?pred .
1733
                      ?ri npa:rolePredicate   ?pred ;
1734
                          npa:bindingSubject  ?instSpace ;
1735
                          npa:bindingObject   ?agent .
1736
                      BIND(npa:inverseProperty AS ?dirPred)
1737
                    }
1738
                    UNION
1739
                    {
1740
                      ?rd gen:hasRegularProperty ?pred .
1741
                      ?ri npa:rolePredicate   ?pred ;
1742
                          npa:bindingObject   ?instSpace ;
1743
                          npa:bindingSubject  ?agent .
1744
                      BIND(npa:regularProperty AS ?dirPred)
1745
                    }
1746
                    # 4. Common instantiation columns. ?instSpace was resolved to this ref
1747
                    #    above (canonical or owl:sameAs alias), so an alias-named instantiation
1748
                    #    joins the same ?spaceRef as a canonical one. The materialized row still
1749
                    #    carries npa:forSpace ?space (the attachment's IRI) for the transitional
1750
                    #    dual-emit, so pre-ref reads see the member under the space's primary IRI.
1751
                    ?ri a gen:RoleInstantiation ;
1752
                        npa:pubkeyHash ?pkh ;
1753
                        npa:viaNanopub ?np .
1754
                    # Candidate grant timestamp for the revocation latest-wins (issue #129);
1755
                    # absent ⇒ epoch (always loses).
1756
                    OPTIONAL { ?ri <http://purl.org/dc/terms/created> ?candCreatedRaw . }
1757
                  }
1758
                  BIND(COALESCE(?candCreatedRaw, %11$s) AS ?candCreated)
1759
                  # 5. Publisher constraint (incl. AccountState resolution).
1760
                  GRAPH <%3$s> {
1761
                    %8$s
1762
                  }
1763
                  # 5a. Mint the per-ref state subject: (?ri, ?spaceRef) → ?ri2.
1764
                  BIND(IRI(CONCAT(STR(?ri), "__", ENCODE_FOR_URI(STR(?spaceRef)))) AS ?ri2)
1765
                  # 6. Load-number filter on bound ?np.
1766
                  GRAPH <%9$s> {
1767
                    ?np npa:hasLoadNumber ?ln .
1768
                    FILTER (?ln > %5$d)
1769
                  }
1770
                  # 7. Instantiation invalidation filter — outside the GRAPH block so the
1771
                  #    planner defers it until ?np is bound. Role-DECLARATION invalidation is
1772
                  #    deliberately NOT consulted: the tier already anchors on the admin-
1773
                  #    validated attachment (?ra), which is removed when an admin retracts it,
1774
                  #    so admin control is fully enforced there. Letting the declaration's
1775
                  #    author (usually not the space admin) supersede/retract their declaration
1776
                  #    strip a space's members is the same cross-author-strip anti-pattern as
1777
                  #    issue #112. Role IRIs are version-pinned, so the attached definition is
1778
                  #    immutable regardless of the declaration nanopub's later lifecycle.
1779
                  %6$s
1780
                  # 7a. Revocation latest-wins (issue #129): suppress if a newer authorized
1781
                  #     gen:RevokedRoleInstantiation shadows this (space, agent, role) key.
1782
                  %10$s
1783
                  # 8. Dedup last — keyed on (ref, agent, nanopub).
1784
                  FILTER NOT EXISTS { GRAPH <%3$s> {
1785
                    ?existing a gen:RoleInstantiation ;
1786
                              npa:forSpaceRef ?spaceRef ;
1787
                              npa:forAgent ?agent ;
1788
                              npa:viaNanopub ?np .
1789
                  } }
1790
                }
1791
                """.formatted(
3✔
1792
                NPA.NAMESPACE,
1793
                GEN.NAMESPACE,
1794
                graph,
1795
                SpacesVocab.SPACES_GRAPH,
1796
                lastProcessed,
15✔
1797
                invalidationFilter("np"),
54✔
1798
                tierClass,
1799
                publisherConstraint,
1800
                NPA.GRAPH,
1801
                nonAdminRevocationSuppressionFilter(graph, tierClass),
18✔
1802
                EPOCH_DT);
1803
    }
1804

1805
    /**
1806
     * Sub-space admit pass. Copies validated {@code npa:SubSpaceDeclaration}
1807
     * extraction rows into the space-state graph (preserving the {@code npasub:}
1808
     * subject) and emits convenience {@code <child> npa:isSubSpaceOf <parent>} and
1809
     * {@code <parent> npa:hasSubSpace <child>} direct triples. Two satisfaction
1810
     * modes joined by UNION:
1811
     * <ul>
1812
     *   <li>Mode A — the declaration's publisher is a validated admin of both the
1813
     *       child and the parent space.</li>
1814
     *   <li>Mode B — a different non-invalidated declaration for the same
1815
     *       {@code (child, parent)} pair exists, and the two publishers between
1816
     *       them cover both admin sides (i.e. one of them is admin of the child,
1817
     *       one of them is admin of the parent — possibly the same one twice if
1818
     *       both happen to be admin of both).</li>
1819
     * </ul>
1820
     *
1821
     * <p>Mode-B late-arrival: when only the partner declaration is new in this
1822
     * cycle (the primary is older than {@code lastProcessed}), the load-number
1823
     * filter on {@code ?np} excludes the candidate. The late-arrival sweep
1824
     * ({@link #runDownstreamWithoutLoadFilter}) re-runs this pass without the
1825
     * load filter and catches it.
1826
     */
1827
    static String subSpaceAdmitUpdate(IRI graph, long lastProcessed) {
1828
        return """
69✔
1829
                PREFIX npa: <%1$s>
1830
                PREFIX gen: <%2$s>
1831
                INSERT { GRAPH <%3$s> {
1832
                  ?d a npa:SubSpaceDeclaration ;
1833
                     npa:childSpace  ?child ;
1834
                     npa:parentSpace ?parent ;
1835
                     npa:viaNanopub  ?np .
1836
                  ?childRef  npa:isSubSpaceOf ?parentRef .
1837
                  ?parentRef npa:hasSubSpace  ?childRef  .
1838
                  # Reified per-(nanopub, ref-pair) provenance link (issue #125 finding #5):
1839
                  # carries npa:viaNanopub plus both the ref and IRI endpoints, so the
1840
                  # invalidation cleanup can drop the convenience edges below once no
1841
                  # surviving link backs them — instead of leaving them sticky until the
1842
                  # next periodic full rebuild.
1843
                  ?ssLink a npa:SubSpaceLink ;
1844
                          npa:viaNanopub     ?np ;
1845
                          npa:childSpaceRef  ?childRef ;
1846
                          npa:parentSpaceRef ?parentRef ;
1847
                          npa:childSpace     ?child ;
1848
                          npa:parentSpace    ?parent .
1849
                  # TRANSITIONAL-DUAL-EMIT (Phase 1.5; remove in Phase 4): IRI-valued
1850
                  # sub-space edge alongside the ref-to-ref one, so pre-ref published
1851
                  # queries that key on the bare Space IRI keep binding on a mixed-version
1852
                  # fleet. See doc/report-2026-06-12-mixed-fleet-spaceref-breakage.md.
1853
                  ?child  npa:isSubSpaceOf ?parent .
1854
                  ?parent npa:hasSubSpace  ?child  .
1855
                } }
1856
                WHERE {
1857
                  # 1. Anchor: candidate declarations from the extraction graph.
1858
                  GRAPH <%4$s> {
1859
                    ?d a npa:SubSpaceDeclaration ;
1860
                       npa:childSpace  ?child ;
1861
                       npa:parentSpace ?parent ;
1862
                       npa:pubkeyHash  ?pkh ;
1863
                       npa:viaNanopub  ?np .
1864
                  }
1865
                  # 2. Mirror: resolve ?pkh → ?publisher via the trust-approved row.
1866
                  GRAPH <%3$s> {
1867
                    ?acct a npa:AccountState ;
1868
                          npa:pubkey ?pkh ;
1869
                          npa:agent  ?publisher .
1870
                  }
1871
                  # 3. Authority gate, ref-keyed. The edge is emitted ref-to-ref between
1872
                  #    the child ref and parent ref the authorizing admin governs; the
1873
                  #    admin rows' dual-emitted npa:forSpace binds the refs to the child /
1874
                  #    parent IRIs (cross-product when an IRI has several governed refs).
1875
                  {
1876
                    # Mode A — publisher is admin of BOTH a child ref and a parent ref.
1877
                    GRAPH <%3$s> {
1878
                      ?riC a gen:RoleInstantiation ;
1879
                           npa:inverseProperty gen:hasAdmin ;
1880
                           npa:forSpace ?child ;
1881
                           npa:forSpaceRef ?childRef ;
1882
                           npa:forAgent ?publisher .
1883
                      ?riP a gen:RoleInstantiation ;
1884
                           npa:inverseProperty gen:hasAdmin ;
1885
                           npa:forSpace ?parent ;
1886
                           npa:forSpaceRef ?parentRef ;
1887
                           npa:forAgent ?publisher .
1888
                    }
1889
                  }
1890
                  UNION
1891
                  {
1892
                    # Mode B — co-declaration whose publisher covers the side this
1893
                    # one's publisher doesn't. Between {publisher, publisher2},
1894
                    # both admin sides must be covered.
1895
                    GRAPH <%4$s> {
1896
                      ?d2 a npa:SubSpaceDeclaration ;
1897
                          npa:childSpace  ?child ;
1898
                          npa:parentSpace ?parent ;
1899
                          npa:pubkeyHash  ?pkh2 ;
1900
                          npa:viaNanopub  ?np2 .
1901
                      FILTER (?np2 != ?np)
1902
                    }
1903
                    %8$s
1904
                    GRAPH <%3$s> {
1905
                      ?acct2 a npa:AccountState ;
1906
                             npa:pubkey ?pkh2 ;
1907
                             npa:agent  ?publisher2 .
1908
                      ?riA a gen:RoleInstantiation ;
1909
                           npa:inverseProperty gen:hasAdmin ;
1910
                           npa:forSpace ?child ;
1911
                           npa:forSpaceRef ?childRef .
1912
                      { ?riA npa:forAgent ?publisher } UNION { ?riA npa:forAgent ?publisher2 }
1913
                      ?riB a gen:RoleInstantiation ;
1914
                           npa:inverseProperty gen:hasAdmin ;
1915
                           npa:forSpace ?parent ;
1916
                           npa:forSpaceRef ?parentRef .
1917
                      { ?riB npa:forAgent ?publisher } UNION { ?riB npa:forAgent ?publisher2 }
1918
                    }
1919
                  }
1920
                  # 4. Invalidation filter on the primary declaration's nanopub.
1921
                  %6$s
1922
                  # 5. Load-number filter on bound ?np.
1923
                  GRAPH <%7$s> {
1924
                    ?np npa:hasLoadNumber ?ln .
1925
                    FILTER (?ln > %5$d)
1926
                  }
1927
                  # 6. Mint the per-(nanopub, ref-pair) provenance link IRI and dedup on it
1928
                  #    (not on the bare edge). Keyed on ?np so every backing declaration of
1929
                  #    the same ref-pair records its own removable link; the convenience
1930
                  #    edges above are re-asserted idempotently.
1931
                  BIND(IRI(CONCAT("http://purl.org/nanopub/admin/spacelink/subspace/",
1932
                                  MD5(CONCAT(STR(?np), "|", STR(?childRef), "|", STR(?parentRef))))) AS ?ssLink)
1933
                  FILTER NOT EXISTS { GRAPH <%3$s> {
1934
                    ?ssLink a npa:SubSpaceLink .
1935
                  } }
1936
                }
1937
                """.formatted(
3✔
1938
                NPA.NAMESPACE,
1939
                GEN.NAMESPACE,
1940
                graph,
1941
                SpacesVocab.SPACES_GRAPH,
1942
                lastProcessed,
15✔
1943
                invalidationFilter("np"),
27✔
1944
                NPA.GRAPH,
1945
                invalidationFilter("np2"));
6✔
1946
    }
1947

1948
    /**
1949
     * Maintained-resource admit pass. Copies validated
1950
     * {@code npa:MaintainedResourceDeclaration} extraction rows into the space-state
1951
     * graph (preserving the {@code npamrd:} subject) and emits convenience
1952
     * {@code <r> npa:isMaintainedBy <s>} and {@code <s> npa:hasMaintainedResource <r>}
1953
     * direct triples. Single satisfaction mode:
1954
     * <ul>
1955
     *   <li>Mode A — the declaration's publisher is a validated admin of the
1956
     *       maintaining space.</li>
1957
     * </ul>
1958
     *
1959
     * <p>No Mode B because only one space is involved; the two-sides-must-be-covered
1960
     * concern that drives sub-space Mode B doesn't apply. Late-arrival is still
1961
     * possible (declaration lands before the publisher's admin grant becomes valid):
1962
     * the load-number filter on {@code ?np} excludes the candidate, and the
1963
     * late-arrival sweep ({@link #runDownstreamWithoutLoadFilter}) re-runs this pass
1964
     * without the load filter and catches it.
1965
     */
1966
    static String maintainedResourceAdmitUpdate(IRI graph, long lastProcessed) {
1967
        return """
69✔
1968
                PREFIX npa: <%1$s>
1969
                PREFIX gen: <%2$s>
1970
                INSERT { GRAPH <%3$s> {
1971
                  ?d a npa:MaintainedResourceDeclaration ;
1972
                     npa:resourceIri     ?r ;
1973
                     npa:maintainerSpace ?s ;
1974
                     npa:viaNanopub      ?np .
1975
                  ?r npa:isMaintainedBy        ?sRef .
1976
                  ?sRef npa:hasMaintainedResource ?r .
1977
                  # Uniform ref-valued resource→governing-space-ref edge (issue #130). The
1978
                  # same predicate the reflexive space self-edge uses, so a single consumer
1979
                  # hop covers both "resource maintained by space S" and "resource IS a space".
1980
                  # Backed by the same MaintainedResourceLink below, so the invalidation
1981
                  # cleanup sweeps it alongside isMaintainedBy.
1982
                  ?r npa:hasGoverningSpaceRef  ?sRef .
1983
                  # Reified per-(nanopub, resource→ref) provenance link (issue #125 finding
1984
                  # #5): lets the invalidation cleanup drop the convenience edges below once
1985
                  # no surviving link backs them, instead of leaving them sticky.
1986
                  ?mrLink a npa:MaintainedResourceLink ;
1987
                          npa:viaNanopub         ?np ;
1988
                          npa:resourceIri        ?r ;
1989
                          npa:maintainerSpaceRef ?sRef ;
1990
                          npa:maintainerSpace    ?s .
1991
                  # TRANSITIONAL-DUAL-EMIT (Phase 1.5; remove in Phase 4): IRI-valued
1992
                  # maintained-resource edge alongside the resource→ref one, so pre-ref
1993
                  # published queries (e.g. get-view-displays' maintained hop) keep binding
1994
                  # on a mixed-version fleet. This is the edge whose absence broke 1.15.0 —
1995
                  # see doc/report-2026-06-12-mixed-fleet-spaceref-breakage.md.
1996
                  ?r npa:isMaintainedBy        ?s .
1997
                  ?s npa:hasMaintainedResource ?r .
1998
                } }
1999
                WHERE {
2000
                  # 1. Anchor: candidate declarations from the extraction graph.
2001
                  GRAPH <%4$s> {
2002
                    ?d a npa:MaintainedResourceDeclaration ;
2003
                       npa:resourceIri     ?r ;
2004
                       npa:maintainerSpace ?s ;
2005
                       npa:pubkeyHash      ?pkh ;
2006
                       npa:viaNanopub      ?np .
2007
                  }
2008
                  # 2. Mirror: resolve ?pkh → ?publisher via the trust-approved row.
2009
                  GRAPH <%3$s> {
2010
                    ?acct a npa:AccountState ;
2011
                          npa:pubkey ?pkh ;
2012
                          npa:agent  ?publisher .
2013
                    # 3. Authority gate (Mode A only): publisher is admin of a ref of the
2014
                    #    maintaining space. ?sRef = that ref (resource → ref edge).
2015
                    ?riA a gen:RoleInstantiation ;
2016
                         npa:inverseProperty gen:hasAdmin ;
2017
                         npa:forSpace ?s ;
2018
                         npa:forSpaceRef ?sRef ;
2019
                         npa:forAgent ?publisher .
2020
                  }
2021
                  # 4. Invalidation filter on the declaration's nanopub.
2022
                  %6$s
2023
                  # 5. Load-number filter on bound ?np.
2024
                  GRAPH <%7$s> {
2025
                    ?np npa:hasLoadNumber ?ln .
2026
                    FILTER (?ln > %5$d)
2027
                  }
2028
                  # 6. Mint the per-(nanopub, resource→ref) provenance link IRI and dedup on
2029
                  #    it (not on the bare edge), so every backing declaration records its own
2030
                  #    removable link; the convenience edges above are re-asserted idempotently.
2031
                  BIND(IRI(CONCAT("http://purl.org/nanopub/admin/spacelink/maintained/",
2032
                                  MD5(CONCAT(STR(?np), "|", STR(?r), "|", STR(?sRef))))) AS ?mrLink)
2033
                  FILTER NOT EXISTS { GRAPH <%3$s> {
2034
                    ?mrLink a npa:MaintainedResourceLink .
2035
                  } }
2036
                }
2037
                """.formatted(
3✔
2038
                NPA.NAMESPACE,
2039
                GEN.NAMESPACE,
2040
                graph,
2041
                SpacesVocab.SPACES_GRAPH,
2042
                lastProcessed,
15✔
2043
                invalidationFilter("np"),
18✔
2044
                NPA.GRAPH);
2045
    }
2046

2047
    /**
2048
     * Space-alias admit pass (issue #113). Copies validated
2049
     * {@code npa:SpaceAliasDeclaration} extraction rows into the space-state graph
2050
     * (preserving the {@code npaalias:} subject) and emits the directional
2051
     * {@code <alias> npa:sameAsSpace <canonical>} edge consumed by the alias-aware
2052
     * admin-authority lookups in {@link #attachmentValidationUpdate},
2053
     * {@link #PUBLISHER_IS_ADMIN}, and {@link #publisherIsTieredRole}.
2054
     *
2055
     * <p>Two gates, both read against the (already-settled) admin closure in the
2056
     * space-state graph:
2057
     * <ul>
2058
     *   <li><b>Authority</b> — the declaration's publisher (resolved via the mirrored
2059
     *       trust-approved {@code AccountState}) is a validated admin of the
2060
     *       <em>canonical</em> space. The alias is declared inside the canonical
2061
     *       space's own {@code gen:Space} nanopub, so this is the same evidence rule
2062
     *       as a {@code gen:hasRole} attachment.</li>
2063
     *   <li><b>Anti-hijack</b> — the alias must not be an independently-governed live
2064
     *       space: it must have no admin who is not also an admin of the canonical
2065
     *       space ({@code admins(alias) ⊆ admins(canonical)}). The common rename case
2066
     *       (the alias's own definition was superseded, so it has no live admin
2067
     *       closure) passes trivially; an attacker publishing
2068
     *       {@code <evil> owl:sameAs <activeSpace>} is rejected because the active
2069
     *       space has admins not in evil's set.</li>
2070
     * </ul>
2071
     *
2072
     * <p>Late-arrival: when the canonical admin grant only becomes valid in the same
2073
     * cycle as the declaration, the load-number filter on {@code ?np} excludes the
2074
     * candidate; the late-arrival sweep ({@link #runDownstreamWithoutLoadFilter})
2075
     * re-runs this pass without the load filter and catches it.
2076
     */
2077
    static String aliasAdmitUpdate(IRI graph, long lastProcessed) {
2078
        // Ref-keyed (see doc/design-spaceref-isolation.md). The declaration names bare
2079
        // canonical/alias IRIs. It is admitted per canonical *ref* whose admin set
2080
        // contains the publisher; the emitted edge is ref-valued on the canonical side
2081
        // (<alias> npa:sameAsSpace <canonicalRef>), which is what the alias-aware admin
2082
        // lookups in the attachment tier consume. Anti-hijack compares the alias IRI's
2083
        // admins against that specific canonical ref's admins — strictly tighter than the
2084
        // old bare-IRI form.
2085
        return """
69✔
2086
                PREFIX npa: <%1$s>
2087
                PREFIX gen: <%2$s>
2088
                INSERT { GRAPH <%3$s> {
2089
                  ?d a npa:SpaceAliasDeclaration ;
2090
                     npa:canonicalSpace ?canonical ;
2091
                     npa:aliasSpace     ?alias ;
2092
                     npa:viaNanopub     ?np .
2093
                  ?alias npa:sameAsSpace ?canonRef .
2094
                  # Reified per-(nanopub, alias→canonical ref) provenance link (issue #125
2095
                  # finding #5). The alias edge feeds the admin-authority closure, so this
2096
                  # is the load-bearing case: the cleanup can now drop the edge when its
2097
                  # declaration is invalidated, rather than letting admin authority outlive
2098
                  # a retraction until the next periodic full rebuild.
2099
                  ?alLink a npa:SpaceAliasLink ;
2100
                          npa:viaNanopub        ?np ;
2101
                          npa:aliasSpace        ?alias ;
2102
                          npa:canonicalSpaceRef ?canonRef ;
2103
                          npa:canonicalSpace    ?canonical .
2104
                  # TRANSITIONAL-DUAL-EMIT (Phase 1.5; remove in Phase 4): IRI-valued
2105
                  # alias edge alongside the ref-valued one, so pre-ref published queries
2106
                  # that resolve owl:sameAs by bare canonical IRI keep binding on a
2107
                  # mixed-version fleet. Internal alias-aware lookups (attachment tier)
2108
                  # join through npa:forSpaceRef, which is ref-valued, so this IRI-valued
2109
                  # object never satisfies them — it is inert internally, read-only for
2110
                  # legacy consumers. See doc/report-2026-06-12-mixed-fleet-spaceref-breakage.md.
2111
                  ?alias npa:sameAsSpace ?canonical .
2112
                } }
2113
                WHERE {
2114
                  # 1. Anchor: candidate alias declarations from the extraction graph.
2115
                  GRAPH <%4$s> {
2116
                    ?d a npa:SpaceAliasDeclaration ;
2117
                       npa:canonicalSpace ?canonical ;
2118
                       npa:aliasSpace     ?alias ;
2119
                       npa:pubkeyHash     ?pkh ;
2120
                       npa:viaNanopub     ?np .
2121
                  }
2122
                  # 2. Authority gate per canonical ref: ?canonRef is a ref of ?canonical
2123
                  #    whose admin set contains the declaration's publisher.
2124
                  GRAPH <%4$s> { ?canonRef npa:spaceIri ?canonical . }
2125
                  GRAPH <%3$s> {
2126
                    ?acct a npa:AccountState ;
2127
                          npa:pubkey ?pkh ;
2128
                          npa:agent  ?publisher .
2129
                    ?adminRI a gen:RoleInstantiation ;
2130
                             npa:inverseProperty gen:hasAdmin ;
2131
                             npa:forSpaceRef ?canonRef ;
2132
                             npa:forAgent ?publisher .
2133
                  }
2134
                  # 3. Anti-hijack: the alias IRI must have no admin who is not also an
2135
                  #    admin of this canonical ref (admins(alias) ⊆ admins(canonRef)).
2136
                  FILTER NOT EXISTS {
2137
                    GRAPH <%3$s> {
2138
                      ?aliasAdmin a gen:RoleInstantiation ;
2139
                                  npa:inverseProperty gen:hasAdmin ;
2140
                                  npa:forSpace ?alias ;
2141
                                  npa:forAgent ?otherAgent .
2142
                    }
2143
                    FILTER NOT EXISTS {
2144
                      GRAPH <%3$s> {
2145
                        ?canonAdmin a gen:RoleInstantiation ;
2146
                                    npa:inverseProperty gen:hasAdmin ;
2147
                                    npa:forSpaceRef ?canonRef ;
2148
                                    npa:forAgent ?otherAgent .
2149
                      }
2150
                    }
2151
                  }
2152
                  # 4. Invalidation filter on the declaration's nanopub.
2153
                  %6$s
2154
                  # 5. Load-number filter on bound ?np.
2155
                  GRAPH <%7$s> {
2156
                    ?np npa:hasLoadNumber ?ln .
2157
                    FILTER (?ln > %5$d)
2158
                  }
2159
                  # 6. Mint the per-(nanopub, alias→canonical ref) provenance link IRI and
2160
                  #    dedup on it (not on the bare edge), so every backing declaration records
2161
                  #    its own removable link; the convenience edges above are re-asserted
2162
                  #    idempotently.
2163
                  BIND(IRI(CONCAT("http://purl.org/nanopub/admin/spacelink/alias/",
2164
                                  MD5(CONCAT(STR(?np), "|", STR(?alias), "|", STR(?canonRef))))) AS ?alLink)
2165
                  FILTER NOT EXISTS { GRAPH <%3$s> {
2166
                    ?alLink a npa:SpaceAliasLink .
2167
                  } }
2168
                }
2169
                """.formatted(
3✔
2170
                NPA.NAMESPACE,
2171
                GEN.NAMESPACE,
2172
                graph,
2173
                SpacesVocab.SPACES_GRAPH,
2174
                lastProcessed,
15✔
2175
                invalidationFilter("np"),
18✔
2176
                NPA.GRAPH);
2177
    }
2178

2179
    /**
2180
     * URL-prefix sub-space fallback admit pass. For every pair of {@code SpaceRef}
2181
     * aggregates where the child's {@code npa:hasIdPrefix} matches the parent's
2182
     * {@code npa:spaceIri}, emits convenience {@code <child> npa:isSubSpaceOf <parent>}
2183
     * and {@code <parent> npa:hasSubSpace <child>} direct triples plus a reified
2184
     * {@code npa:DerivedSubSpaceLink} tag carrying {@code npa:derivationKind
2185
     * npa:byUrlPrefix} so consumers can hide derived edges.
2186
     *
2187
     * <p>Per-child suppression: any validated {@code npa:SubSpaceDeclaration} on the
2188
     * child in {@code npass:<…>} suppresses every fallback edge for that child.
2189
     * Suppression checks the validated set (not raw extraction-graph declarations)
2190
     * so an unapproved or in-flight Mode B declaration doesn't silently hide both
2191
     * the URL-prefix fallback and the (still-invalid) explicit relation.
2192
     *
2193
     * <p>Run order: must run after {@link #subSpaceAdmitUpdate} commits in the
2194
     * same cycle so the suppression check sees this cycle's freshly-validated
2195
     * declarations.
2196
     *
2197
     * <p>No load-number filter: the fallback depends on which Spaces exist (parent
2198
     * + child {@code SpaceRef}s), not on which were just added. Always full-scan;
2199
     * the dedup {@code FILTER NOT EXISTS} on the tag IRI prevents re-insertion.
2200
     *
2201
     * <p>No invalidation handling: derived edges have no source nanopub. Two
2202
     * staleness modes: (a) child later gets first validated declaration → old
2203
     * derived edges stay sticky until the next periodic rebuild (same policy as
2204
     * admin-RI invalidation); (b) child loses last validated declaration → the
2205
     * regular fallback pass on the next cycle re-engages, adds derived edges
2206
     * incrementally, no rebuild needed.
2207
     */
2208
    static String subSpacePrefixFallbackUpdate(IRI graph) {
2209
        return """
48✔
2210
                PREFIX npa: <%1$s>
2211
                INSERT { GRAPH <%2$s> {
2212
                  ?childRef  npa:isSubSpaceOf ?parentRef .
2213
                  ?parentRef npa:hasSubSpace  ?childRef  .
2214
                  # TRANSITIONAL-DUAL-EMIT (Phase 1.5; remove in Phase 4): IRI-valued
2215
                  # derived sub-space edge alongside the ref-to-ref one, mirroring the
2216
                  # explicit sub-space pass, so pre-ref published queries keep binding on a
2217
                  # mixed-version fleet. See doc/report-2026-06-12-mixed-fleet-spaceref-breakage.md.
2218
                  ?child  npa:isSubSpaceOf ?parent .
2219
                  ?parent npa:hasSubSpace  ?child  .
2220
                  ?tagIri a npa:DerivedSubSpaceLink ;
2221
                          npa:childSpace     ?child ;
2222
                          npa:parentSpace    ?parent ;
2223
                          # Ref endpoints too (issue #125 finding #5), so the sub-space
2224
                          # orphan-sweep recognizes a prefix-derived ref edge as backed and
2225
                          # never deletes it. Derived links have no source nanopub, so they
2226
                          # are never invalidation-deleted; the fallback self-heals each cycle.
2227
                          npa:childSpaceRef  ?childRef ;
2228
                          npa:parentSpaceRef ?parentRef ;
2229
                          npa:derivationKind npa:byUrlPrefix .
2230
                } }
2231
                WHERE {
2232
                  # 1. Anchor: child SpaceRef → its path-prefixes (extracted at load
2233
                  #    time from the Space IRI; see SpacesExtractor.enumerateIdPrefixes).
2234
                  GRAPH <%3$s> {
2235
                    ?childRef  npa:spaceIri    ?child ;
2236
                               npa:hasIdPrefix ?parent .
2237
                    # 2. Parent SpaceRef must exist for the same IRI as the prefix.
2238
                    ?parentRef npa:spaceIri    ?parent .
2239
                  }
2240
                  # 3. Suppress fallback for any child that has a validated declaration
2241
                  #    in this state graph. Per-child IRI, all-or-nothing.
2242
                  FILTER NOT EXISTS {
2243
                    GRAPH <%2$s> {
2244
                      ?d a npa:SubSpaceDeclaration ;
2245
                         npa:childSpace ?child .
2246
                    }
2247
                  }
2248
                  # 4. Mint a deterministic tag IRI per (child ref, parent ref) — the edge
2249
                  #    is emitted ref-to-ref, so the tag and dedup are per ref-pair.
2250
                  BIND(IRI(CONCAT("http://purl.org/nanopub/admin/derivedlink/",
2251
                                  MD5(CONCAT(STR(?childRef), "|", STR(?parentRef))))) AS ?tagIri)
2252
                  # 5. Dedup: don't re-insert if this tag is already present.
2253
                  FILTER NOT EXISTS {
2254
                    GRAPH <%2$s> {
2255
                      ?tagIri a npa:DerivedSubSpaceLink .
2256
                    }
2257
                  }
2258
                }
2259
                """.formatted(
3✔
2260
                NPA.NAMESPACE,
2261
                graph,
2262
                SpacesVocab.SPACES_GRAPH);
2263
    }
2264

2265
    /**
2266
     * Reflexive governing-space-ref pass (issue #130). For every {@code SpaceRef}
2267
     * aggregate {@code ?spaceRef} (identified by {@code npa:spaceIri ?space} in the
2268
     * extraction graph), emits {@code <space> npa:hasGoverningSpaceRef <spaceRef>} into
2269
     * the space-state graph — the space pointing at its own ref through the same predicate
2270
     * a maintained resource uses to point at its maintaining space's ref (emitted in
2271
     * {@link #maintainedResourceAdmitUpdate}).
2272
     *
2273
     * <p>This removes the zero-hop special case from consumer authority gates: instead of
2274
     * {@code ?resource npa:isMaintainedBy? ?space} (a bare-IRI optional path that breaks
2275
     * once the hop is ref-valued), a consumer does a single mandatory
2276
     * {@code ?resource npa:hasGoverningSpaceRef ?spaceRef} that binds whether the resource
2277
     * is a maintained resource or a space itself. A space IRI claimed by several refs emits
2278
     * one edge per ref — the non-ref consumer variant's merged-across-refs behaviour falls
2279
     * out naturally; the ref variant pins {@code ?passedRef}.
2280
     *
2281
     * <p>Self-healing, like {@link #subSpacePrefixFallbackUpdate}: the edge has no source
2282
     * nanopub (it follows purely from a {@code SpaceRef} existing), so there is no
2283
     * invalidation handling and no load-number filter — always full-scan, with the dedup
2284
     * {@code FILTER NOT EXISTS} on the edge preventing re-insertion. A {@code SpaceRef}
2285
     * disappearing is itself a structural-rebuild event, which clears its reflexive edge.
2286
     */
2287
    static String governingSpaceRefReflexiveUpdate(IRI graph) {
2288
        return """
48✔
2289
                PREFIX npa: <%1$s>
2290
                INSERT { GRAPH <%2$s> {
2291
                  ?space npa:hasGoverningSpaceRef ?spaceRef .
2292
                } }
2293
                WHERE {
2294
                  GRAPH <%3$s> { ?spaceRef npa:spaceIri ?space . }
2295
                  FILTER NOT EXISTS { GRAPH <%2$s> {
2296
                    ?space npa:hasGoverningSpaceRef ?spaceRef .
2297
                  } }
2298
                }
2299
                """.formatted(
3✔
2300
                NPA.NAMESPACE,
2301
                graph,
2302
                SpacesVocab.SPACES_GRAPH);
2303
    }
2304

2305
    // ---------------- Invalidation templates (incremental cycle) ----------------
2306

2307
    /**
2308
     * WHERE clause shared by the admin-RI invalidation ASK precheck and the
2309
     * matching DELETE. Identifies admin-tier {@code gen:RoleInstantiation} rows
2310
     * in the space-state graph whose {@code npa:viaNanopub} is the target of an
2311
     * {@code npx:invalidates} triple in {@code npa:graph} whose subject nanopub
2312
     * has a load number in {@code (lastProcessed, ∞)}.
2313
     */
2314
    static String adminInvalidationCheckWhere(IRI graph, long lastProcessed) {
2315
        return String.format("""
60✔
2316
                  GRAPH <%1$s> {
2317
                    ?ri a gen:RoleInstantiation ;
2318
                        npa:inverseProperty gen:hasAdmin ;
2319
                        npa:viaNanopub ?np .
2320
                  }
2321
                  GRAPH <%2$s> {
2322
                    ?invNp <%3$s> ?np ;
2323
                           npa:hasLoadNumber ?ln .
2324
                    FILTER (?ln > %4$d)
2325
                    %5$s
2326
                  }
2327
                """, graph, NPA.GRAPH, NPX.INVALIDATES, lastProcessed,
18✔
2328
                samePublisherClause("invNp", "np"));
6✔
2329
    }
2330

2331
    /** DELETE template for admin-tier RoleInstantiations whose source nanopub was invalidated. */
2332
    static String adminInvalidationDelete(IRI graph, long lastProcessed) {
2333
        return String.format("""
63✔
2334
                PREFIX npa: <%1$s>
2335
                PREFIX gen: <%2$s>
2336
                DELETE { GRAPH <%3$s> {
2337
                  ?ri ?p ?o .
2338
                } }
2339
                WHERE {
2340
                  GRAPH <%3$s> { ?ri ?p ?o . }
2341
                %4$s
2342
                }
2343
                """, NPA.NAMESPACE, GEN.NAMESPACE, graph,
2344
                adminInvalidationCheckWhere(graph, lastProcessed));
6✔
2345
    }
2346

2347
    /** WHERE clause for RoleAssignment invalidation. */
2348
    static String roleAssignmentInvalidationCheckWhere(IRI graph, long lastProcessed) {
2349
        return String.format("""
60✔
2350
                  GRAPH <%1$s> {
2351
                    ?ra a gen:RoleAssignment ;
2352
                        npa:viaNanopub ?np .
2353
                  }
2354
                  GRAPH <%2$s> {
2355
                    ?invNp <%3$s> ?np ;
2356
                           npa:hasLoadNumber ?ln .
2357
                    FILTER (?ln > %4$d)
2358
                    %5$s
2359
                  }
2360
                """, graph, NPA.GRAPH, NPX.INVALIDATES, lastProcessed,
18✔
2361
                samePublisherClause("invNp", "np"));
6✔
2362
    }
2363

2364
    /** DELETE template for RoleAssignments whose source nanopub was invalidated. */
2365
    static String roleAssignmentInvalidationDelete(IRI graph, long lastProcessed) {
2366
        return String.format("""
63✔
2367
                PREFIX npa: <%1$s>
2368
                PREFIX gen: <%2$s>
2369
                DELETE { GRAPH <%3$s> {
2370
                  ?ra ?p ?o .
2371
                } }
2372
                WHERE {
2373
                  GRAPH <%3$s> { ?ra ?p ?o . }
2374
                %4$s
2375
                }
2376
                """, NPA.NAMESPACE, GEN.NAMESPACE, graph,
2377
                roleAssignmentInvalidationCheckWhere(graph, lastProcessed));
6✔
2378
    }
2379

2380
    /**
2381
     * DELETE template for non-admin (leaf-tier) RoleInstantiations whose source
2382
     * nanopub was invalidated. Identified as {@code gen:RoleInstantiation} rows
2383
     * lacking the admin-pinning {@code npa:inverseProperty gen:hasAdmin} triple.
2384
     * No flag is set; leaf-tier removals are recoverable on the next cycle.
2385
     */
2386
    static String leafTierInvalidationDelete(IRI graph, long lastProcessed) {
2387
        return String.format("""
84✔
2388
                PREFIX npa: <%1$s>
2389
                PREFIX gen: <%2$s>
2390
                DELETE { GRAPH <%3$s> {
2391
                  ?ri ?p ?o .
2392
                } }
2393
                WHERE {
2394
                  GRAPH <%3$s> {
2395
                    ?ri a gen:RoleInstantiation ;
2396
                        npa:viaNanopub ?np .
2397
                    FILTER NOT EXISTS { ?ri npa:inverseProperty gen:hasAdmin }
2398
                    ?ri ?p ?o .
2399
                  }
2400
                  GRAPH <%4$s> {
2401
                    ?invNp <%5$s> ?np ;
2402
                           npa:hasLoadNumber ?ln .
2403
                    FILTER (?ln > %6$d)
2404
                    %7$s
2405
                  }
2406
                }
2407
                """, NPA.NAMESPACE, GEN.NAMESPACE, graph,
2408
                NPA.GRAPH, NPX.INVALIDATES, lastProcessed,
18✔
2409
                samePublisherClause("invNp", "np"));
6✔
2410
    }
2411

2412
    /**
2413
     * WHERE clause shared by the sub-space invalidation ASK precheck and the
2414
     * matching DELETE. Identifies validated {@code npa:SubSpaceDeclaration} rows
2415
     * in the space-state graph whose {@code npa:viaNanopub} is the target of an
2416
     * {@code npx:invalidates} triple in {@code npa:graph} whose subject nanopub
2417
     * has a load number in {@code (lastProcessed, ∞)}.
2418
     */
2419
    static String subSpaceInvalidationCheckWhere(IRI graph, long lastProcessed) {
2420
        return String.format("""
60✔
2421
                  GRAPH <%1$s> {
2422
                    ?d a npa:SubSpaceDeclaration ;
2423
                       npa:viaNanopub ?np .
2424
                  }
2425
                  GRAPH <%2$s> {
2426
                    ?invNp <%3$s> ?np ;
2427
                           npa:hasLoadNumber ?ln .
2428
                    FILTER (?ln > %4$d)
2429
                    %5$s
2430
                  }
2431
                """, graph, NPA.GRAPH, NPX.INVALIDATES, lastProcessed,
18✔
2432
                samePublisherClause("invNp", "np"));
6✔
2433
    }
2434

2435
    /**
2436
     * DELETE template for validated {@code npa:SubSpaceDeclaration} rows whose
2437
     * source nanopub was invalidated. Removes the per-declaration row by subject;
2438
     * the convenience direct triples ({@code <child> npa:isSubSpaceOf <parent>}
2439
     * and inverse) are then dropped by {@link #subSpaceConvenienceEdgeCleanup} in the
2440
     * same cycle (issue #125 finding #5) once no surviving link backs them.
2441
     */
2442
    static String subSpaceInvalidationDelete(IRI graph, long lastProcessed) {
2443
        return String.format("""
63✔
2444
                PREFIX npa: <%1$s>
2445
                PREFIX gen: <%2$s>
2446
                DELETE { GRAPH <%3$s> {
2447
                  ?d ?p ?o .
2448
                } }
2449
                WHERE {
2450
                  GRAPH <%3$s> { ?d ?p ?o . }
2451
                %4$s
2452
                }
2453
                """, NPA.NAMESPACE, GEN.NAMESPACE, graph,
2454
                subSpaceInvalidationCheckWhere(graph, lastProcessed));
6✔
2455
    }
2456

2457
    /**
2458
     * DELETE template for validated {@code npa:MaintainedResourceDeclaration} rows
2459
     * whose source nanopub was invalidated. Removes the per-declaration row by
2460
     * subject; the convenience direct triples ({@code <r> npa:isMaintainedBy <s>}
2461
     * and inverse) are then dropped by {@link #maintainedResourceConvenienceEdgeCleanup}
2462
     * in the same cycle (issue #125 finding #5). No structural-rebuild flag —
2463
     * maintained-resource is a leaf relation, no downstream consumers depend on its
2464
     * closure, so the prompt edge cleanup fully resolves its invalidation.
2465
     */
2466
    static String maintainedResourceInvalidationDelete(IRI graph, long lastProcessed) {
2467
        return String.format("""
84✔
2468
                PREFIX npa: <%1$s>
2469
                PREFIX gen: <%2$s>
2470
                DELETE { GRAPH <%3$s> {
2471
                  ?d ?p ?o .
2472
                } }
2473
                WHERE {
2474
                  GRAPH <%3$s> {
2475
                    ?d a npa:MaintainedResourceDeclaration ;
2476
                       npa:viaNanopub ?np .
2477
                    ?d ?p ?o .
2478
                  }
2479
                  GRAPH <%4$s> {
2480
                    ?invNp <%5$s> ?np ;
2481
                           npa:hasLoadNumber ?ln .
2482
                    FILTER (?ln > %6$d)
2483
                    %7$s
2484
                  }
2485
                }
2486
                """, NPA.NAMESPACE, GEN.NAMESPACE, graph,
2487
                NPA.GRAPH, NPX.INVALIDATES, lastProcessed,
18✔
2488
                samePublisherClause("invNp", "np"));
6✔
2489
    }
2490

2491
    /**
2492
     * WHERE clause shared by the alias invalidation ASK precheck and the matching
2493
     * DELETE. Identifies validated {@code npa:SpaceAliasDeclaration} rows in the
2494
     * space-state graph whose {@code npa:viaNanopub} is the target of an
2495
     * {@code npx:invalidates} triple in {@code npa:graph} whose subject nanopub has a
2496
     * load number in {@code (lastProcessed, ∞)}.
2497
     */
2498
    static String aliasInvalidationCheckWhere(IRI graph, long lastProcessed) {
2499
        return String.format("""
60✔
2500
                  GRAPH <%1$s> {
2501
                    ?d a npa:SpaceAliasDeclaration ;
2502
                       npa:viaNanopub ?np .
2503
                  }
2504
                  GRAPH <%2$s> {
2505
                    ?invNp <%3$s> ?np ;
2506
                           npa:hasLoadNumber ?ln .
2507
                    FILTER (?ln > %4$d)
2508
                    %5$s
2509
                  }
2510
                """, graph, NPA.GRAPH, NPX.INVALIDATES, lastProcessed,
18✔
2511
                samePublisherClause("invNp", "np"));
6✔
2512
    }
2513

2514
    /**
2515
     * DELETE template for validated {@code npa:SpaceAliasDeclaration} rows whose
2516
     * source nanopub was invalidated. Removes the per-declaration row by subject; the
2517
     * convenience {@code <alias> npa:sameAsSpace <canonical>} edge is then dropped by
2518
     * {@link #aliasConvenienceEdgeCleanup} in the same cycle (issue #125 finding #5),
2519
     * so an alias can no longer grant admin authority after its declaration is retracted.
2520
     * The alias feeds the authority closure, so this kind is still structural and flips
2521
     * {@code npa:needsFullRebuild} to bound any rows already derived through the edge.
2522
     */
2523
    static String aliasInvalidationDelete(IRI graph, long lastProcessed) {
2524
        return String.format("""
63✔
2525
                PREFIX npa: <%1$s>
2526
                PREFIX gen: <%2$s>
2527
                DELETE { GRAPH <%3$s> {
2528
                  ?d ?p ?o .
2529
                } }
2530
                WHERE {
2531
                  GRAPH <%3$s> { ?d ?p ?o . }
2532
                %4$s
2533
                }
2534
                """, NPA.NAMESPACE, GEN.NAMESPACE, graph,
2535
                aliasInvalidationCheckWhere(graph, lastProcessed));
6✔
2536
    }
2537

2538
    /**
2539
     * WHERE clause shared by the maintained-resource invalidation ASK precheck and the
2540
     * matching cleanup. Identifies validated {@code npa:MaintainedResourceDeclaration}
2541
     * rows in the space-state graph whose {@code npa:viaNanopub} is the target of an
2542
     * {@code npx:invalidates} triple in {@code npa:graph} whose subject nanopub has a
2543
     * load number in {@code (lastProcessed, ∞)}.
2544
     */
2545
    static String maintainedResourceInvalidationCheckWhere(IRI graph, long lastProcessed) {
2546
        return String.format("""
×
2547
                  GRAPH <%1$s> {
2548
                    ?d a npa:MaintainedResourceDeclaration ;
2549
                       npa:viaNanopub ?np .
2550
                  }
2551
                  GRAPH <%2$s> {
2552
                    ?invNp <%3$s> ?np ;
2553
                           npa:hasLoadNumber ?ln .
2554
                    FILTER (?ln > %4$d)
2555
                    %5$s
2556
                  }
2557
                """, graph, NPA.GRAPH, NPX.INVALIDATES, lastProcessed,
×
2558
                samePublisherClause("invNp", "np"));
×
2559
    }
2560

2561
    /**
2562
     * Convenience-edge cleanup for invalidated sub-space declarations (issue #125
2563
     * finding #5). Run after {@link #subSpaceInvalidationDelete} (which removes the
2564
     * {@code npa:SubSpaceDeclaration} rows). Two phases as one multi-operation update:
2565
     * <ol>
2566
     *   <li>delete every {@code npa:SubSpaceLink} provenance link whose
2567
     *       {@code npa:viaNanopub} was invalidated (same {@code npx:invalidates} +
2568
     *       same-publisher gate as the declaration delete);</li>
2569
     *   <li>orphan-sweep: delete the convenience {@code npa:isSubSpaceOf} /
2570
     *       {@code npa:hasSubSpace} edges (both ref- and IRI-valued) that no surviving
2571
     *       link backs — neither a {@code npa:SubSpaceLink} (explicit declaration) nor a
2572
     *       {@code npa:DerivedSubSpaceLink} (URL-prefix fallback).</li>
2573
     * </ol>
2574
     * Edges backed by another surviving declaration or by the URL-prefix fallback are
2575
     * kept. The {@code npa:needsFullRebuild} flag still fires for the structural kind, so
2576
     * downstream rows derived through a removed edge remain rebuild-bounded; this only
2577
     * stops the convenience edges themselves from going sticky.
2578
     */
2579
    static String subSpaceConvenienceEdgeCleanup(IRI graph, long lastProcessed) {
2580
        return String.format("""
72✔
2581
                PREFIX npa: <%1$s>
2582
                # 1. Drop sub-space provenance links whose source nanopub was invalidated.
2583
                DELETE { GRAPH <%2$s> { ?l ?p ?o . } }
2584
                WHERE {
2585
                  GRAPH <%2$s> {
2586
                    ?l a npa:SubSpaceLink ;
2587
                       npa:viaNanopub ?np .
2588
                    ?l ?p ?o .
2589
                  }
2590
                  GRAPH <%3$s> {
2591
                    ?invNp <%4$s> ?np ;
2592
                           npa:hasLoadNumber ?ln .
2593
                    FILTER (?ln > %5$d)
2594
                    %6$s
2595
                  }
2596
                } ;
2597
                # 2. Orphan-sweep isSubSpaceOf edges (ref- and IRI-valued) with no backing link.
2598
                DELETE { GRAPH <%2$s> { ?c npa:isSubSpaceOf ?p . } }
2599
                WHERE {
2600
                  GRAPH <%2$s> {
2601
                    ?c npa:isSubSpaceOf ?p .
2602
                    FILTER NOT EXISTS {
2603
                      { ?l a npa:SubSpaceLink } UNION { ?l a npa:DerivedSubSpaceLink }
2604
                      { { ?l npa:childSpaceRef ?c . ?l npa:parentSpaceRef ?p }
2605
                        UNION
2606
                        { ?l npa:childSpace ?c . ?l npa:parentSpace ?p } }
2607
                    }
2608
                  }
2609
                } ;
2610
                # 3. Orphan-sweep the inverse hasSubSpace edges symmetrically.
2611
                DELETE { GRAPH <%2$s> { ?p npa:hasSubSpace ?c . } }
2612
                WHERE {
2613
                  GRAPH <%2$s> {
2614
                    ?p npa:hasSubSpace ?c .
2615
                    FILTER NOT EXISTS {
2616
                      { ?l a npa:SubSpaceLink } UNION { ?l a npa:DerivedSubSpaceLink }
2617
                      { { ?l npa:childSpaceRef ?c . ?l npa:parentSpaceRef ?p }
2618
                        UNION
2619
                        { ?l npa:childSpace ?c . ?l npa:parentSpace ?p } }
2620
                    }
2621
                  }
2622
                }
2623
                """, NPA.NAMESPACE, graph, NPA.GRAPH, NPX.INVALIDATES, lastProcessed,
18✔
2624
                samePublisherClause("invNp", "np"));
6✔
2625
    }
2626

2627
    /**
2628
     * Convenience-edge cleanup for invalidated maintained-resource declarations (issue
2629
     * #125 finding #5). Run after {@link #maintainedResourceInvalidationDelete}. Deletes
2630
     * the {@code npa:MaintainedResourceLink} provenance links whose source nanopub was
2631
     * invalidated, then orphan-sweeps the {@code npa:isMaintainedBy} /
2632
     * {@code npa:hasMaintainedResource} edges (ref- and IRI-valued) that no surviving link
2633
     * backs. See {@link #subSpaceConvenienceEdgeCleanup} for the two-phase structure.
2634
     */
2635
    static String maintainedResourceConvenienceEdgeCleanup(IRI graph, long lastProcessed) {
2636
        return String.format("""
72✔
2637
                PREFIX npa: <%1$s>
2638
                # 1. Drop maintained-resource provenance links whose source nanopub was invalidated.
2639
                DELETE { GRAPH <%2$s> { ?l ?p ?o . } }
2640
                WHERE {
2641
                  GRAPH <%2$s> {
2642
                    ?l a npa:MaintainedResourceLink ;
2643
                       npa:viaNanopub ?np .
2644
                    ?l ?p ?o .
2645
                  }
2646
                  GRAPH <%3$s> {
2647
                    ?invNp <%4$s> ?np ;
2648
                           npa:hasLoadNumber ?ln .
2649
                    FILTER (?ln > %5$d)
2650
                    %6$s
2651
                  }
2652
                } ;
2653
                # 2. Orphan-sweep isMaintainedBy edges (ref- and IRI-valued) with no backing link.
2654
                DELETE { GRAPH <%2$s> { ?r npa:isMaintainedBy ?o . } }
2655
                WHERE {
2656
                  GRAPH <%2$s> {
2657
                    ?r npa:isMaintainedBy ?o .
2658
                    FILTER NOT EXISTS {
2659
                      ?l a npa:MaintainedResourceLink ;
2660
                         npa:resourceIri ?r .
2661
                      { ?l npa:maintainerSpaceRef ?o } UNION { ?l npa:maintainerSpace ?o }
2662
                    }
2663
                  }
2664
                } ;
2665
                # 3. Orphan-sweep the inverse hasMaintainedResource edges symmetrically.
2666
                DELETE { GRAPH <%2$s> { ?o npa:hasMaintainedResource ?r . } }
2667
                WHERE {
2668
                  GRAPH <%2$s> {
2669
                    ?o npa:hasMaintainedResource ?r .
2670
                    FILTER NOT EXISTS {
2671
                      ?l a npa:MaintainedResourceLink ;
2672
                         npa:resourceIri ?r .
2673
                      { ?l npa:maintainerSpaceRef ?o } UNION { ?l npa:maintainerSpace ?o }
2674
                    }
2675
                  }
2676
                } ;
2677
                # 4. Orphan-sweep the maintained arm of hasGoverningSpaceRef (issue #130).
2678
                #    Only the ref-valued maintained edge is removed here — it is backed by a
2679
                #    MaintainedResourceLink. The reflexive space self-edge (subject = a space
2680
                #    IRI that has its own SpaceRef) is NOT a maintained edge and is left to the
2681
                #    self-healing reflexive pass, so the guard keeps any ?r that is itself a space.
2682
                DELETE { GRAPH <%2$s> { ?r npa:hasGoverningSpaceRef ?o . } }
2683
                WHERE {
2684
                  GRAPH <%2$s> {
2685
                    ?r npa:hasGoverningSpaceRef ?o .
2686
                    FILTER NOT EXISTS {
2687
                      ?l a npa:MaintainedResourceLink ;
2688
                         npa:resourceIri ?r ;
2689
                         npa:maintainerSpaceRef ?o .
2690
                    }
2691
                    FILTER NOT EXISTS { GRAPH <%7$s> { ?o npa:spaceIri ?r . } }
2692
                  }
2693
                }
2694
                """, NPA.NAMESPACE, graph, NPA.GRAPH, NPX.INVALIDATES, lastProcessed,
18✔
2695
                samePublisherClause("invNp", "np"), SpacesVocab.SPACES_GRAPH);
18✔
2696
    }
2697

2698
    /**
2699
     * Convenience-edge cleanup for invalidated space-alias declarations (issue #125
2700
     * finding #5 — the load-bearing case, since the alias edge feeds the admin-authority
2701
     * closure). Run after {@link #aliasInvalidationDelete}. Deletes the
2702
     * {@code npa:SpaceAliasLink} provenance links whose source nanopub was invalidated,
2703
     * then orphan-sweeps the {@code npa:sameAsSpace} edges (ref- and IRI-valued) that no
2704
     * surviving link backs. See {@link #subSpaceConvenienceEdgeCleanup} for the two-phase
2705
     * structure.
2706
     */
2707
    static String aliasConvenienceEdgeCleanup(IRI graph, long lastProcessed) {
2708
        return String.format("""
72✔
2709
                PREFIX npa: <%1$s>
2710
                # 1. Drop alias provenance links whose source nanopub was invalidated.
2711
                DELETE { GRAPH <%2$s> { ?l ?p ?o . } }
2712
                WHERE {
2713
                  GRAPH <%2$s> {
2714
                    ?l a npa:SpaceAliasLink ;
2715
                       npa:viaNanopub ?np .
2716
                    ?l ?p ?o .
2717
                  }
2718
                  GRAPH <%3$s> {
2719
                    ?invNp <%4$s> ?np ;
2720
                           npa:hasLoadNumber ?ln .
2721
                    FILTER (?ln > %5$d)
2722
                    %6$s
2723
                  }
2724
                } ;
2725
                # 2. Orphan-sweep sameAsSpace edges (ref- and IRI-valued) with no backing link.
2726
                DELETE { GRAPH <%2$s> { ?alias npa:sameAsSpace ?o . } }
2727
                WHERE {
2728
                  GRAPH <%2$s> {
2729
                    ?alias npa:sameAsSpace ?o .
2730
                    FILTER NOT EXISTS {
2731
                      ?l a npa:SpaceAliasLink ;
2732
                         npa:aliasSpace ?alias .
2733
                      { ?l npa:canonicalSpaceRef ?o } UNION { ?l npa:canonicalSpace ?o }
2734
                    }
2735
                  }
2736
                }
2737
                """, NPA.NAMESPACE, graph, NPA.GRAPH, NPX.INVALIDATES, lastProcessed,
18✔
2738
                samePublisherClause("invNp", "np"));
6✔
2739
    }
2740

2741
    /**
2742
     * WHERE clause shared by the preset-deactivation ASK precheck and the matching DELETE
2743
     * (Nanodash issue #302). Binds {@code ?ra} = a materialized preset-derived
2744
     * {@code gen:RoleAssignment} ({@code npa:derivedFromPreset}) for which a <em>newer,
2745
     * admin-authored</em> same-{@code (preset, resource)} assignment exists by
2746
     * {@code dct:created} (load number in {@code (lastProcessed, ∞)}). This is NOT an
2747
     * {@code npx:invalidates} check — preset activation is latest-wins by timestamp.
2748
     *
2749
     * <p>Authorization-scoped (anti-hijack, design doc §3/§4.4): the newer assignment's
2750
     * publisher must itself be a validated admin of the row's {@code npa:forSpaceRef}, so an
2751
     * unauthorized key's newer assignment can neither delete nor shadow an admin's
2752
     * materialized role. {@code dct:created} is written as a full IRI (not a {@code dct:}
2753
     * prefix) because {@link #wouldInvalidate}'s ASK wrapper only declares {@code npa:} /
2754
     * {@code gen:}.
2755
     */
2756
    static String presetDeactivationCheckWhere(IRI graph, long lastProcessed) {
2757
        return String.format("""
60✔
2758
                  GRAPH <%1$s> {
2759
                    ?ra a gen:RoleAssignment ;
2760
                        npa:derivedFromPreset ?assignNp ;
2761
                        npa:forSpaceRef ?targetRef .
2762
                  }
2763
                  GRAPH <%2$s> {
2764
                    ?pa a npa:PresetAssignment ;
2765
                        npa:viaNanopub  ?assignNp ;
2766
                        npa:ofPreset    ?preset ;
2767
                        npa:forResource ?resource ;
2768
                        <http://purl.org/dc/terms/created> ?created .
2769
                    ?paNewer a npa:PresetAssignment ;
2770
                             npa:ofPreset    ?preset ;
2771
                             npa:forResource ?resource ;
2772
                             npa:pubkeyHash  ?pkhNewer ;
2773
                             npa:viaNanopub  ?assignNpNewer ;
2774
                             <http://purl.org/dc/terms/created> ?createdNewer .
2775
                    FILTER (?createdNewer > ?created
2776
                            || (?createdNewer = ?created && STR(?paNewer) > STR(?pa)))
2777
                  }
2778
                  GRAPH <%3$s> {
2779
                    ?assignNpNewer npa:hasLoadNumber ?lnNewer .
2780
                    FILTER (?lnNewer > %4$d)
2781
                  }
2782
                  GRAPH <%1$s> {
2783
                    ?acctNewer a npa:AccountState ;
2784
                               npa:agent  ?publisherNewer ;
2785
                               npa:pubkey ?pkhNewer .
2786
                    ?adminRINewer a gen:RoleInstantiation ;
2787
                                  npa:forSpaceRef ?targetRef ;
2788
                                  npa:inverseProperty gen:hasAdmin ;
2789
                                  npa:forAgent ?publisherNewer .
2790
                  }
2791
                """, graph, SpacesVocab.SPACES_GRAPH, NPA.GRAPH, lastProcessed);
6✔
2792
    }
2793

2794
    /**
2795
     * DELETE template for preset-derived {@code gen:RoleAssignment} rows superseded by a
2796
     * newer admin-authored same-pair assignment (issue #302). Removes the whole row by
2797
     * subject; scoped via {@code npa:derivedFromPreset} so directly-published attachments
2798
     * are never touched. The {@link #presetAttachmentValidationUpdate} re-INSERT in the
2799
     * same cycle re-materializes the pair iff the newest assignment is still active.
2800
     */
2801
    static String presetDeactivationDelete(IRI graph, long lastProcessed) {
2802
        return String.format("""
63✔
2803
                PREFIX npa: <%1$s>
2804
                PREFIX gen: <%2$s>
2805
                DELETE { GRAPH <%3$s> {
2806
                  ?ra ?p ?o .
2807
                } }
2808
                WHERE {
2809
                  GRAPH <%3$s> { ?ra ?p ?o . }
2810
                %4$s
2811
                }
2812
                """, NPA.NAMESPACE, GEN.NAMESPACE, graph,
2813
                presetDeactivationCheckWhere(graph, lastProcessed));
6✔
2814
    }
2815

2816
    /**
2817
     * WHERE clause matching a materialized <em>non-admin</em> {@code gen:RoleInstantiation}
2818
     * row whose {@code (forSpaceRef, forAgent, gen:hasRole)} key is shadowed by a newer
2819
     * authorized {@code npa:RoleRevocation} (issue #129). The grant timestamp comes from the
2820
     * originating instantiation in the extraction graph (the materialized row carries no
2821
     * {@code dct:created}); the revocation nanopub's load number must be in
2822
     * {@code (lastProcessed, ∞)} so only revocations new in this cycle trigger a delete.
2823
     * Authorization is keyed on the row's bound {@code ?tier} (the matrix: a strictly-higher
2824
     * tier in the ref, or self). Not an {@code npx:invalidates} check.
2825
     */
2826
    static String roleRevocationCheckWhere(IRI graph, long lastProcessed, IRI targetTier) {
2827
        return String.format("""
60✔
2828
                  GRAPH <%1$s> {
2829
                    ?ri2 a gen:RoleInstantiation ;
2830
                         npa:forSpaceRef ?spaceRef ;
2831
                         npa:forAgent    ?agent ;
2832
                         gen:hasRole     ?role ;
2833
                         npa:hasRoleType <%7$s> ;
2834
                         npa:viaNanopub  ?np .
2835
                  }
2836
                  OPTIONAL { GRAPH <%2$s> {
2837
                    ?riSrc npa:viaNanopub ?np ;
2838
                           <http://purl.org/dc/terms/created> ?candCreatedRaw .
2839
                  } }
2840
                  BIND(COALESCE(?candCreatedRaw, %5$s) AS ?candCreated)
2841
                  { GRAPH <%2$s> { ?spaceRef npa:spaceIri ?revSpace . } }
2842
                  UNION
2843
                  { GRAPH <%1$s> { ?revSpace npa:sameAsSpace ?spaceRef . } }
2844
                  GRAPH <%2$s> {
2845
                    ?rev a npa:RoleRevocation ;
2846
                         npa:forSpace    ?revSpace ;
2847
                         npa:forAgent    ?agent ;
2848
                         npa:revokedRole ?role ;
2849
                         npa:pubkeyHash  ?revPkh ;
2850
                         npa:viaNanopub  ?revNp .
2851
                    OPTIONAL { ?rev <http://purl.org/dc/terms/created> ?revCreatedRaw . }
2852
                  }
2853
                  BIND(COALESCE(?revCreatedRaw, %5$s) AS ?revCreated)
2854
                  GRAPH <%3$s> {
2855
                    ?revNp npa:hasLoadNumber ?lnRev .
2856
                    FILTER (?lnRev > %4$d)
2857
                  }
2858
                  FILTER (?revCreated > ?candCreated
2859
                          || (?revCreated = ?candCreated && STR(?rev) > STR(?ri2)))
2860
                  { %6$s }
2861
                """, graph, SpacesVocab.SPACES_GRAPH, NPA.GRAPH, lastProcessed,
30✔
2862
                EPOCH_DT, revocationAuthorityArmsForTier(graph, targetTier), targetTier);
18✔
2863
    }
2864

2865
    /**
2866
     * DELETE template removing a non-admin {@code gen:RoleInstantiation} row of {@code
2867
     * targetTier} shadowed by a newer authorized revocation (issue #129). Removes the whole
2868
     * row by subject. Run once per non-admin tier (maintainer/member/observer) so the
2869
     * authorization arms are the compile-time set for that tier — matching the inline
2870
     * suppression filter, no runtime {@code ?tier} (see {@link #revocationAuthorityArmsForTier}).
2871
     * Caller sets {@code needsFullRebuild} (a revoked maintainer/member is a sub-granting
2872
     * authority).
2873
     */
2874
    static String roleRevocationDelete(IRI graph, long lastProcessed, IRI targetTier) {
2875
        return String.format("""
66✔
2876
                PREFIX npa: <%1$s>
2877
                PREFIX gen: <%2$s>
2878
                DELETE { GRAPH <%3$s> {
2879
                  ?ri2 ?p ?o .
2880
                } }
2881
                WHERE {
2882
                  GRAPH <%3$s> { ?ri2 ?p ?o . }
2883
                %4$s
2884
                }
2885
                """, NPA.NAMESPACE, GEN.NAMESPACE, graph,
2886
                roleRevocationCheckWhere(graph, lastProcessed, targetTier));
6✔
2887
    }
2888

2889
    /**
2890
     * WHERE clause matching a materialized <em>admin</em> {@code gen:RoleInstantiation} row
2891
     * whose {@code (forSpaceRef, forAgent)} key is shadowed by a newer authorized admin
2892
     * {@code npa:RoleRevocation} ({@code revokedRole = gen:AdminRole}), authorized by an
2893
     * admin of the ref or by the agent itself. <b>Root admins are exempt</b> (constitutional,
2894
     * issue #129/#110): the nested {@code FILTER NOT EXISTS} on {@code npa:hasRootAdmin}
2895
     * makes the revocation inert. The revocation nanopub's load number must be in
2896
     * {@code (lastProcessed, ∞)}.
2897
     */
2898
    static String adminRevocationCheckWhere(IRI graph, long lastProcessed) {
2899
        return String.format("""
60✔
2900
                  GRAPH <%1$s> {
2901
                    ?sri a gen:RoleInstantiation ;
2902
                         npa:forSpaceRef     ?spaceRef ;
2903
                         npa:inverseProperty gen:hasAdmin ;
2904
                         npa:forAgent        ?agent ;
2905
                         npa:viaNanopub      ?np .
2906
                  }
2907
                  FILTER NOT EXISTS { GRAPH <%2$s> {
2908
                    ?rootDef a npa:SpaceDefinition ;
2909
                             npa:forSpaceRef  ?spaceRef ;
2910
                             npa:hasRootAdmin ?agent .
2911
                  } }
2912
                  OPTIONAL { GRAPH <%2$s> {
2913
                    ?riSrc npa:viaNanopub ?np ;
2914
                           <http://purl.org/dc/terms/created> ?candCreatedRaw .
2915
                  } }
2916
                  BIND(COALESCE(?candCreatedRaw, %5$s) AS ?candCreated)
2917
                  { GRAPH <%2$s> { ?spaceRef npa:spaceIri ?revSpace . } }
2918
                  UNION
2919
                  { GRAPH <%1$s> { ?revSpace npa:sameAsSpace ?spaceRef . } }
2920
                  GRAPH <%2$s> {
2921
                    ?rev a npa:RoleRevocation ;
2922
                         npa:forSpace    ?revSpace ;
2923
                         npa:forAgent    ?agent ;
2924
                         npa:revokedRole gen:AdminRole ;
2925
                         npa:pubkeyHash  ?revPkh ;
2926
                         npa:viaNanopub  ?revNp .
2927
                    OPTIONAL { ?rev <http://purl.org/dc/terms/created> ?revCreatedRaw . }
2928
                  }
2929
                  BIND(COALESCE(?revCreatedRaw, %5$s) AS ?revCreated)
2930
                  GRAPH <%3$s> {
2931
                    ?revNp npa:hasLoadNumber ?lnRev .
2932
                    FILTER (?lnRev > %4$d)
2933
                  }
2934
                  FILTER (?revCreated > ?candCreated
2935
                          || (?revCreated = ?candCreated && STR(?rev) > STR(?sri)))
2936
                  { %6$s }
2937
                """, graph, SpacesVocab.SPACES_GRAPH, NPA.GRAPH, lastProcessed, EPOCH_DT,
27✔
2938
                "{ " + revokerAdminGraphBlock(graph) + " }\nUNION\n{ "
6✔
2939
                        + revokerSelfGraphBlock(graph) + " }");
9✔
2940
    }
2941

2942
    /**
2943
     * DELETE template removing an admin {@code gen:RoleInstantiation} row shadowed by a newer
2944
     * authorized admin revocation (issue #129). Removes the whole row by subject.
2945
     * <b>Structural</b> — admin RIs feed every downstream tier — so the caller sets
2946
     * {@code npa:needsFullRebuild} (mirrors {@code adminInvalidationDelete}). The
2947
     * {@code adminTierUpdate} inline suppression filter prevents re-materialization.
2948
     */
2949
    static String adminRevocationDelete(IRI graph, long lastProcessed) {
2950
        return String.format("""
63✔
2951
                PREFIX npa: <%1$s>
2952
                PREFIX gen: <%2$s>
2953
                DELETE { GRAPH <%3$s> {
2954
                  ?sri ?p ?o .
2955
                } }
2956
                WHERE {
2957
                  GRAPH <%3$s> { ?sri ?p ?o . }
2958
                %4$s
2959
                }
2960
                """, NPA.NAMESPACE, GEN.NAMESPACE, graph,
2961
                adminRevocationCheckWhere(graph, lastProcessed));
6✔
2962
    }
2963

2964
    /**
2965
     * WHERE clause matching a materialized {@code gen:RoleAssignment} row (direct
2966
     * <em>or</em> preset-derived) whose {@code (forSpaceRef, gen:hasRole)} key is shadowed by
2967
     * a newer admin-authored {@code npa:RoleDetachment} (issue #129). The attachment
2968
     * timestamp comes from whichever extraction row shares the materialized row's
2969
     * {@code npa:viaNanopub} (a {@code RoleAssignment} for direct attachments, a
2970
     * {@code PresetAssignment} for preset-derived ones). The detachment nanopub's load number
2971
     * must be in {@code (lastProcessed, ∞)}; authority = admin of the ref.
2972
     */
2973
    static String roleDetachmentCheckWhere(IRI graph, long lastProcessed) {
2974
        return String.format("""
60✔
2975
                  GRAPH <%1$s> {
2976
                    ?ra2 a gen:RoleAssignment ;
2977
                         npa:forSpaceRef ?targetRef ;
2978
                         gen:hasRole     ?role ;
2979
                         npa:viaNanopub  ?np .
2980
                  }
2981
                  OPTIONAL { GRAPH <%2$s> {
2982
                    ?attSrc npa:viaNanopub ?np ;
2983
                            <http://purl.org/dc/terms/created> ?attCreatedRaw .
2984
                  } }
2985
                  BIND(COALESCE(?attCreatedRaw, %5$s) AS ?attCreated)
2986
                  { GRAPH <%2$s> { ?targetRef npa:spaceIri ?detSpace . } }
2987
                  UNION
2988
                  { GRAPH <%1$s> { ?detSpace npa:sameAsSpace ?targetRef . } }
2989
                  GRAPH <%2$s> {
2990
                    ?det a npa:RoleDetachment ;
2991
                         npa:forSpace    ?detSpace ;
2992
                         npa:revokedRole ?role ;
2993
                         npa:pubkeyHash  ?detPkh ;
2994
                         npa:viaNanopub  ?detNp .
2995
                    OPTIONAL { ?det <http://purl.org/dc/terms/created> ?detCreatedRaw . }
2996
                  }
2997
                  BIND(COALESCE(?detCreatedRaw, %5$s) AS ?detCreated)
2998
                  GRAPH <%3$s> {
2999
                    ?detNp npa:hasLoadNumber ?lnDet .
3000
                    FILTER (?lnDet > %4$d)
3001
                  }
3002
                  FILTER (?detCreated > ?attCreated
3003
                          || (?detCreated = ?attCreated && STR(?det) > STR(?ra2)))
3004
                  GRAPH <%1$s> {
3005
                    ?detAcct a npa:AccountState ; npa:pubkey ?detPkh ; npa:agent ?detAgent .
3006
                    ?detAdminRI a gen:RoleInstantiation ;
3007
                                npa:forSpaceRef ?targetRef ;
3008
                                npa:inverseProperty gen:hasAdmin ;
3009
                                npa:forAgent ?detAgent .
3010
                  }
3011
                """, graph, SpacesVocab.SPACES_GRAPH, NPA.GRAPH, lastProcessed, EPOCH_DT);
18✔
3012
    }
3013

3014
    /**
3015
     * DELETE template removing a {@code gen:RoleAssignment} row (direct or preset-derived)
3016
     * shadowed by a newer admin-authored {@code gen:detachedRole} (issue #129). Removes the
3017
     * whole row by subject. <b>Structural</b> — instantiations anchored on the removed
3018
     * attachment are bounded by the periodic full rebuild (the cascade), so the caller sets
3019
     * {@code npa:needsFullRebuild}. The attachment-tier inline filters prevent
3020
     * re-materialization until a newer attachment / preset assignment out-ranks the detach
3021
     * (non-sticky).
3022
     */
3023
    static String roleDetachmentDelete(IRI graph, long lastProcessed) {
3024
        return String.format("""
63✔
3025
                PREFIX npa: <%1$s>
3026
                PREFIX gen: <%2$s>
3027
                DELETE { GRAPH <%3$s> {
3028
                  ?ra2 ?p ?o .
3029
                } }
3030
                WHERE {
3031
                  GRAPH <%3$s> { ?ra2 ?p ?o . }
3032
                %4$s
3033
                }
3034
                """, NPA.NAMESPACE, GEN.NAMESPACE, graph,
3035
                roleDetachmentCheckWhere(graph, lastProcessed));
6✔
3036
    }
3037

3038
    /**
3039
     * DELETE template for ref-scoped preset-assignment stamps ({@link
3040
     * #presetAssignmentRefStampUpdate}) whose underlying assignment nanopub was
3041
     * hard-retracted (issue #122). Removes the whole row by subject; scoped to
3042
     * state-graph {@code npa:PresetAssignment} rows that carry {@code npa:forSpaceRef}
3043
     * (the IRI-keyed extraction rows never do), so it can never touch them.
3044
     *
3045
     * <p>Leaf delete — no structural flag: nothing downstream derives from a listing
3046
     * stamp, so a stale row only mis-displays a retracted assignment until this cycle's
3047
     * delete runs. Admin-grant revocation is bounded by the periodic full rebuild (same
3048
     * sticky-convenience policy as the alias / sub-space declaration edges). A
3049
     * <em>deactivation</em> needs no delete here: it is represented as a newer
3050
     * admin-authored stamp with {@code npa:isActivated false}, resolved by the consumer's
3051
     * latest-wins.
3052
     */
3053
    static String presetAssignmentRefInvalidationDelete(IRI graph, long lastProcessed) {
3054
        return String.format("""
84✔
3055
                PREFIX npa: <%1$s>
3056
                PREFIX gen: <%2$s>
3057
                DELETE { GRAPH <%3$s> {
3058
                  ?paRef ?p ?o .
3059
                } }
3060
                WHERE {
3061
                  GRAPH <%3$s> {
3062
                    ?paRef a npa:PresetAssignment ;
3063
                           npa:forSpaceRef ?targetRef ;
3064
                           npa:viaNanopub  ?assignNp .
3065
                    ?paRef ?p ?o .
3066
                  }
3067
                  GRAPH <%4$s> {
3068
                    ?invNp <%5$s> ?assignNp ;
3069
                           npa:hasLoadNumber ?ln .
3070
                    FILTER (?ln > %6$d)
3071
                    %7$s
3072
                  }
3073
                }
3074
                """, NPA.NAMESPACE, GEN.NAMESPACE, graph,
3075
                NPA.GRAPH, NPX.INVALIDATES, lastProcessed,
18✔
3076
                samePublisherClause("invNp", "assignNp"));
6✔
3077
    }
3078

3079
    /** Wraps an ASK by joining the shared prefixes. */
3080
    private boolean wouldInvalidate(IRI graph, long lastProcessed,
3081
                                    boolean adminPinned, String whereClause) {
3082
        // adminPinned is informational only — kept to make call sites read clearly;
3083
        // the WHERE clause already encodes the kind via its own type predicates.
3084
        String ask = String.format("""
×
3085
                PREFIX npa: <%1$s>
3086
                PREFIX gen: <%2$s>
3087
                ASK { %3$s }
3088
                """, NPA.NAMESPACE, GEN.NAMESPACE, whereClause);
3089
        return runAsk(ask);
×
3090
    }
3091

3092
    private boolean runAsk(String sparql) {
3093
        try (RepositoryConnection conn = TripleStore.get().getRepoConnection(SPACES_REPO)) {
×
3094
            return conn.prepareBooleanQuery(QueryLanguage.SPARQL, sparql).evaluate();
×
3095
        }
3096
    }
3097

3098
    private void executeUpdate(String sparqlUpdate) {
3099
        try (RepositoryConnection conn = TripleStore.get().getRepoConnection(SPACES_REPO)) {
×
3100
            conn.prepareUpdate(QueryLanguage.SPARQL, sparqlUpdate).execute();
×
3101
        }
3102
    }
×
3103

3104
    // ---------------- Mirror step ----------------
3105

3106
    /**
3107
     * Copies trust-approved {@code npa:AccountState} rows from {@code npat:<T>}
3108
     * in the {@code trust} repo into {@code newGraph} in the {@code spaces} repo,
3109
     * inside one spaces-side serializable transaction.
3110
     *
3111
     * @return number of rows mirrored (useful for metrics / logging)
3112
     */
3113
    int mirrorTrustState(String trustStateHash, IRI newGraph) {
3114
        IRI trustStateIri = NPAT.forHash(trustStateHash);
×
3115
        int count = 0;
×
3116
        try (RepositoryConnection trustConn = TripleStore.get().getRepoConnection(TRUST_REPO);
×
3117
             RepositoryConnection spacesConn = TripleStore.get().getRepoConnection(SPACES_REPO)) {
×
3118
            trustConn.begin(IsolationLevels.READ_COMMITTED);
×
3119
            spacesConn.begin(IsolationLevels.SERIALIZABLE);
×
3120
            // Walk rdf:type triples in the trust state's graph; for each AccountState,
3121
            // check status and copy the approved ones verbatim (minus status-specific
3122
            // detail triples, which we don't need for validation).
3123
            try (RepositoryResult<Statement> typeRows = trustConn.getStatements(
×
3124
                    null, RDF.TYPE, NPA_ACCOUNT_STATE, trustStateIri)) {
3125
                while (typeRows.hasNext()) {
×
3126
                    Statement st = typeRows.next();
×
3127
                    if (!(st.getSubject() instanceof IRI accountStateIri)) continue;
×
3128
                    Value status = trustConn.getStatements(accountStateIri, NPA_TRUST_STATUS, null, trustStateIri)
×
3129
                            .stream().findFirst().map(Statement::getObject).orElse(null);
×
3130
                    if (!(status instanceof IRI statusIri) || !APPROVED_SET.contains(statusIri)) continue;
×
3131
                    Value agent = trustConn.getStatements(accountStateIri, NPA_AGENT, null, trustStateIri)
×
3132
                            .stream().findFirst().map(Statement::getObject).orElse(null);
×
3133
                    Value pubkey = trustConn.getStatements(accountStateIri, NPA_PUBKEY, null, trustStateIri)
×
3134
                            .stream().findFirst().map(Statement::getObject).orElse(null);
×
3135
                    if (agent == null || pubkey == null) {
×
3136
                        logger.warn("AuthorityResolver.mirror: account {} missing agent or pubkey; skipping",
×
3137
                                accountStateIri);
3138
                        continue;
×
3139
                    }
3140
                    spacesConn.add(accountStateIri, RDF.TYPE, NPA_ACCOUNT_STATE, newGraph);
×
3141
                    spacesConn.add(accountStateIri, NPA_AGENT, agent, newGraph);
×
3142
                    spacesConn.add(accountStateIri, NPA_PUBKEY, pubkey, newGraph);
×
3143
                    spacesConn.add(accountStateIri, NPA_TRUST_STATUS, statusIri, newGraph);
×
3144
                    // Mirror the authorizing introduction provenance when present (issue #125
3145
                    // finding #4). Optional: absent for snapshots from registries that predate
3146
                    // nanopub-registry#117/#118, so consumers (e.g. get-space-members-ref) must
3147
                    // treat npa:viaNanopub on an AccountState as best-effort, not guaranteed.
3148
                    Value viaNanopub = trustConn.getStatements(accountStateIri, NPA_VIA_NANOPUB, null, trustStateIri)
×
3149
                            .stream().findFirst().map(Statement::getObject).orElse(null);
×
3150
                    if (viaNanopub != null) {
×
3151
                        spacesConn.add(accountStateIri, NPA_VIA_NANOPUB, viaNanopub, newGraph);
×
3152
                    }
3153
                    count++;
×
3154
                }
×
3155
            }
3156
            // Mirror canonical foaf:name triples for approved agents. The trust
3157
            // loader emits one per agent (across approved keys, MAX(ratio) wins).
3158
            // Copying them into the space-state graph means consumers reading
3159
            // ?agent foaf:name ?n inside the state graph hit local data, with no
3160
            // cross-repo SERVICE.
3161
            try (RepositoryResult<Statement> nameRows = trustConn.getStatements(
×
3162
                    null, FOAF.NAME, null, trustStateIri)) {
3163
                while (nameRows.hasNext()) {
×
3164
                    Statement st = nameRows.next();
×
3165
                    spacesConn.add(st.getSubject(), st.getPredicate(), st.getObject(), newGraph);
×
3166
                }
×
3167
            }
3168
            spacesConn.commit();
×
3169
            trustConn.commit();
×
3170
        }
3171
        return count;
×
3172
    }
3173

3174
    // ---------------- Pointer + counter helpers ----------------
3175

3176
    /**
3177
     * Reads the current {@code npa:hasCurrentSpaceState} pointer from the
3178
     * {@code npa:graph} admin graph of the {@code spaces} repo. Returns
3179
     * {@code null} if no pointer exists yet.
3180
     */
3181
    IRI getCurrentSpaceStateGraph() {
3182
        try (RepositoryConnection conn = TripleStore.get().getRepoConnection(SPACES_REPO)) {
×
3183
            Value v = Utils.getObjectForPattern(conn, NPA.GRAPH, NPA.THIS_REPO,
×
3184
                    SpacesVocab.HAS_CURRENT_SPACE_STATE);
3185
            return (v instanceof IRI iri) ? iri : null;
×
3186
        } catch (Exception ex) {
×
3187
            logger.warn("AuthorityResolver: failed to read hasCurrentSpaceState pointer: {}", ex.toString());
×
3188
            return null;
×
3189
        }
3190
    }
3191

3192
    long getCurrentLoadCounter() {
3193
        try (RepositoryConnection conn = TripleStore.get().getRepoConnection(SPACES_REPO)) {
×
3194
            Value v = Utils.getObjectForPattern(conn, NPA.GRAPH, NPA.THIS_REPO,
×
3195
                    SpacesVocab.CURRENT_LOAD_COUNTER);
3196
            if (v == null) return 0;
×
3197
            try {
3198
                return Long.parseLong(v.stringValue());
×
3199
            } catch (NumberFormatException ex) {
×
3200
                logger.warn("AuthorityResolver: non-numeric currentLoadCounter: {}", v);
×
3201
                return 0;
×
3202
            }
3203
        } catch (Exception ex) {
×
3204
            logger.warn("AuthorityResolver: failed to read currentLoadCounter: {}", ex.toString());
×
3205
            return 0;
×
3206
        }
3207
    }
3208

3209
    /**
3210
     * Atomic pointer flip: a single SPARQL {@code DELETE … INSERT … WHERE}
3211
     * replaces the old pointer with the new one in one statement, so readers
3212
     * never see a zero-pointer window.
3213
     */
3214
    void flipPointer(IRI newGraph) {
3215
        String update = String.format("""
×
3216
                DELETE { GRAPH <%s> { <%s> <%s> ?old } }
3217
                INSERT { GRAPH <%s> { <%s> <%s> <%s> } }
3218
                WHERE  { OPTIONAL { GRAPH <%s> { <%s> <%s> ?old } } }
3219
                """,
3220
                NPA.GRAPH, NPA.THIS_REPO, SpacesVocab.HAS_CURRENT_SPACE_STATE,
3221
                NPA.GRAPH, NPA.THIS_REPO, SpacesVocab.HAS_CURRENT_SPACE_STATE, newGraph,
3222
                NPA.GRAPH, NPA.THIS_REPO, SpacesVocab.HAS_CURRENT_SPACE_STATE);
3223
        try (RepositoryConnection conn = TripleStore.get().getRepoConnection(SPACES_REPO)) {
×
3224
            conn.begin(IsolationLevels.SERIALIZABLE);
×
3225
            conn.prepareUpdate(QueryLanguage.SPARQL, update).execute();
×
3226
            conn.commit();
×
3227
        }
3228
    }
×
3229

3230
    void writeProcessedUpTo(IRI graph, long loadCounter) {
3231
        String update = String.format("""
×
3232
                DELETE { GRAPH <%s> { <%s> <%s> ?old } }
3233
                INSERT { GRAPH <%s> { <%s> <%s> "%d"^^<http://www.w3.org/2001/XMLSchema#long> } }
3234
                WHERE  { OPTIONAL { GRAPH <%s> { <%s> <%s> ?old } } }
3235
                """,
3236
                graph, graph, SpacesVocab.PROCESSED_UP_TO,
3237
                graph, graph, SpacesVocab.PROCESSED_UP_TO, loadCounter,
×
3238
                graph, graph, SpacesVocab.PROCESSED_UP_TO);
3239
        try (RepositoryConnection conn = TripleStore.get().getRepoConnection(SPACES_REPO)) {
×
3240
            conn.begin(IsolationLevels.SERIALIZABLE);
×
3241
            conn.prepareUpdate(QueryLanguage.SPARQL, update).execute();
×
3242
            conn.commit();
×
3243
        }
3244
    }
×
3245

3246
    /**
3247
     * Reads {@code processedUpTo} from the given space-state graph.
3248
     * Returns {@code -1} if absent (graph not fully built yet).
3249
     */
3250
    long readProcessedUpTo(IRI graph) {
3251
        try (RepositoryConnection conn = TripleStore.get().getRepoConnection(SPACES_REPO)) {
×
3252
            String query = String.format(
×
3253
                    "SELECT ?n WHERE { GRAPH <%s> { <%s> <%s> ?n } }",
3254
                    graph, graph, SpacesVocab.PROCESSED_UP_TO);
3255
            try (TupleQueryResult r = conn.prepareTupleQuery(QueryLanguage.SPARQL, query).evaluate()) {
×
3256
                if (!r.hasNext()) return -1;
×
3257
                BindingSet b = r.next();
×
3258
                return Long.parseLong(b.getBinding("n").getValue().stringValue());
×
3259
            }
×
3260
        } catch (Exception ex) {
×
3261
            logger.warn("AuthorityResolver: failed to read processedUpTo for {}: {}", graph, ex.toString());
×
3262
            return -1;
×
3263
        }
3264
    }
3265

3266
    /**
3267
     * Reads the {@code npa:needsFullRebuild} flag (boolean literal) from
3268
     * {@code npa:graph} in the {@code spaces} repo. Defaults to {@code false}
3269
     * when the triple is absent.
3270
     */
3271
    boolean readNeedsFullRebuild() {
3272
        try (RepositoryConnection conn = TripleStore.get().getRepoConnection(SPACES_REPO)) {
×
3273
            Value v = Utils.getObjectForPattern(conn, NPA.GRAPH, NPA.THIS_REPO,
×
3274
                    SpacesVocab.NEEDS_FULL_REBUILD);
3275
            return v != null && Boolean.parseBoolean(v.stringValue());
×
3276
        } catch (Exception ex) {
×
3277
            logger.warn("AuthorityResolver: failed to read needsFullRebuild: {}", ex.toString());
×
3278
            return false;
×
3279
        }
3280
    }
3281

3282
    void setNeedsFullRebuild() {
3283
        writeNeedsFullRebuild(true);
×
3284
    }
×
3285

3286
    void clearNeedsFullRebuild() {
3287
        writeNeedsFullRebuild(false);
×
3288
    }
×
3289

3290
    private void writeNeedsFullRebuild(boolean value) {
3291
        String update = String.format("""
×
3292
                DELETE { GRAPH <%s> { <%s> <%s> ?old } }
3293
                INSERT { GRAPH <%s> { <%s> <%s> "%s"^^<http://www.w3.org/2001/XMLSchema#boolean> } }
3294
                WHERE  { OPTIONAL { GRAPH <%s> { <%s> <%s> ?old } } }
3295
                """,
3296
                NPA.GRAPH, NPA.THIS_REPO, SpacesVocab.NEEDS_FULL_REBUILD,
3297
                NPA.GRAPH, NPA.THIS_REPO, SpacesVocab.NEEDS_FULL_REBUILD, value,
×
3298
                NPA.GRAPH, NPA.THIS_REPO, SpacesVocab.NEEDS_FULL_REBUILD);
3299
        try (RepositoryConnection conn = TripleStore.get().getRepoConnection(SPACES_REPO)) {
×
3300
            conn.begin(IsolationLevels.SERIALIZABLE);
×
3301
            conn.prepareUpdate(QueryLanguage.SPARQL, update).execute();
×
3302
            conn.commit();
×
3303
        }
3304
    }
×
3305

3306
    void dropGraph(IRI graph) {
3307
        try (RepositoryConnection conn = TripleStore.get().getRepoConnection(SPACES_REPO)) {
×
3308
            conn.begin(IsolationLevels.SERIALIZABLE);
×
3309
            conn.clear(graph);
×
3310
            conn.commit();
×
3311
            logger.info("AuthorityResolver: dropped old space-state graph {}", graph);
×
3312
        }
3313
    }
×
3314

3315
    // ---------------- Trust-repo pointer lookup (used by TrustStateRegistry's bootstrap) ----------------
3316

3317
    /**
3318
     * Queries the {@code trust} repo directly for the current trust-state hash.
3319
     * Prefer {@link TrustStateRegistry#getCurrentHash()} in normal operation —
3320
     * this helper exists for tests and diagnostics.
3321
     *
3322
     * @return the current trust-state hash, or empty if none is set
3323
     */
3324
    Optional<String> readTrustRepoCurrentHash() {
3325
        try (RepositoryConnection conn = TripleStore.get().getRepoConnection(TRUST_REPO)) {
×
3326
            Value v = Utils.getObjectForPattern(conn, NPA.GRAPH, NPA.THIS_REPO,
×
3327
                    NPA_HAS_CURRENT_TRUST_STATE);
3328
            if (!(v instanceof IRI iri)) return Optional.empty();
×
3329
            String s = iri.stringValue();
×
3330
            if (!s.startsWith(NPAT.NAMESPACE)) return Optional.empty();
×
3331
            return Optional.of(s.substring(NPAT.NAMESPACE.length()));
×
3332
        } catch (Exception ex) {
×
3333
            logger.warn("AuthorityResolver: failed to read trust-repo current pointer: {}", ex.toString());
×
3334
            return Optional.empty();
×
3335
        }
3336
    }
3337

3338
    private static String abbrev(String hash) {
3339
        return hash.length() > 12 ? hash.substring(0, 12) + "…" : hash;
×
3340
    }
3341

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