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

knowledgepixels / nanopub-query / 28515371671

01 Jul 2026 11:50AM UTC coverage: 62.156% (-0.2%) from 62.401%
28515371671

push

github

tkuhn
docs: design for pinning role-predicate direction in role-assigning nanopubs

Root-causes #136 (hasHelper/hasMaintainer invisible to get-space-members
because they stay neutral in the raw spacesGraph the query reads) and lays
out a two-part fix: promote the two predicates into the known-direction map
(rename BackcompatRolePredicates), and a forward pubinfo per-predicate
direction pin so custom roles normalize from the nanopub alone.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

581 of 1040 branches covered (55.87%)

Branch coverage included in aggregate %.

1702 of 2633 relevant lines covered (64.64%)

10.1 hits per line

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

33.21
src/main/java/com/knowledgepixels/query/TrustStateLoader.java
1
package com.knowledgepixels.query;
2

3
import com.google.common.hash.Hashing;
4
import com.knowledgepixels.query.vocabulary.NPAA;
5
import com.knowledgepixels.query.vocabulary.NPAT;
6
import org.apache.http.client.methods.HttpGet;
7
import org.apache.http.impl.client.CloseableHttpClient;
8
import org.apache.http.impl.client.HttpClientBuilder;
9
import org.apache.http.util.EntityUtils;
10
import org.eclipse.rdf4j.common.transaction.IsolationLevels;
11
import org.eclipse.rdf4j.model.IRI;
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.model.vocabulary.XSD;
17
import org.eclipse.rdf4j.query.QueryLanguage;
18
import org.eclipse.rdf4j.query.TupleQueryResult;
19
import org.eclipse.rdf4j.repository.RepositoryConnection;
20
import org.nanopub.vocabulary.NPA;
21
import org.slf4j.Logger;
22
import org.slf4j.LoggerFactory;
23

24
import java.io.IOException;
25
import java.net.URLEncoder;
26
import java.nio.charset.StandardCharsets;
27
import java.util.*;
28

29
/**
30
 * Materializes a registry trust state into the local {@code trust} repository
31
 * when a hash change is detected.
32
 *
33
 * <p>Detection happens in {@link JellyNanopubLoader} (which polls the registry
34
 * every ~2 s anyway and reads {@code Nanopub-Registry-Trust-State-Hash}). This
35
 * class does the rest: fetch {@code /trust-state/<hash>.json}, parse the
36
 * envelope, materialize the snapshot into a named graph, and swap the current
37
 * pointer — all in one serializable transaction.
38
 *
39
 * <p>See {@code doc/design-trust-state-repos.md} for the full design.
40
 */
41
public class TrustStateLoader {
42

43
    private static final Logger logger = LoggerFactory.getLogger(TrustStateLoader.class);
9✔
44

45
    /**
46
     * Local name of the repository that holds all mirrored trust states.
47
     */
48
    static final String TRUST_REPO = "trust";
49

50
    /**
51
     * Default number of historical trust states retained locally. Matches the registry's own snapshot retention.
52
     */
53
    static final int DEFAULT_LOCAL_RETENTION = 100;
54

55
    private static final ValueFactory vf = SimpleValueFactory.getInstance();
6✔
56

57
    // Local extensions to the upstream NPA vocabulary (terms used only on the
58
    // consumer side). Defined here rather than in a vocab class because they're
59
    // strictly internal to the trust-state mirroring code.
60
    private static final IRI NPA_TRUST_STATE = vf.createIRI(NPA.NAMESPACE, "TrustState");
15✔
61
    private static final IRI NPA_ACCOUNT_STATE = vf.createIRI(NPA.NAMESPACE, "AccountState");
15✔
62
    private static final IRI NPA_HAS_TRUST_STATE_HASH = vf.createIRI(NPA.NAMESPACE, "hasTrustStateHash");
15✔
63
    private static final IRI NPA_HAS_TRUST_STATE_COUNTER = vf.createIRI(NPA.NAMESPACE, "hasTrustStateCounter");
15✔
64
    private static final IRI NPA_HAS_CREATED_AT = vf.createIRI(NPA.NAMESPACE, "hasCreatedAt");
15✔
65
    private static final IRI NPA_HAS_CURRENT_TRUST_STATE = vf.createIRI(NPA.NAMESPACE, "hasCurrentTrustState");
15✔
66
    private static final IRI NPA_AGENT = vf.createIRI(NPA.NAMESPACE, "agent");
15✔
67
    private static final IRI NPA_PUBKEY = vf.createIRI(NPA.NAMESPACE, "pubkey");
15✔
68
    private static final IRI NPA_TRUST_STATUS = vf.createIRI(NPA.NAMESPACE, "trustStatus");
15✔
69
    private static final IRI NPA_DEPTH = vf.createIRI(NPA.NAMESPACE, "depth");
15✔
70
    private static final IRI NPA_PATH_COUNT = vf.createIRI(NPA.NAMESPACE, "pathCount");
15✔
71
    private static final IRI NPA_RATIO = vf.createIRI(NPA.NAMESPACE, "ratio");
15✔
72
    private static final IRI NPA_QUOTA = vf.createIRI(NPA.NAMESPACE, "quota");
15✔
73
    private static final IRI NPA_VIA_NANOPUB = vf.createIRI(NPA.NAMESPACE, "viaNanopub");
15✔
74

75
    private static final CloseableHttpClient httpClient =
76
            HttpClientBuilder.create().setDefaultRequestConfig(Utils.getHttpRequestConfig()).build();
15✔
77

78
    private TrustStateLoader() {
79
    }  // no instances
80

81
    /**
82
     * Seeds {@link TrustStateRegistry} from the current-state pointer persisted
83
     * in the {@code trust} repo. Intended to run once on startup, before the
84
     * periodic poll begins — so if the pointer already matches the registry's
85
     * advertised hash, the first poll is a no-op rather than a redundant
86
     * re-materialization.
87
     *
88
     * <p>Safe to call on a fresh deployment (the trust repo may not even exist
89
     * yet — auto-created, found empty, seeded nothing). Any failure is logged
90
     * at INFO; bootstrap falls through and the first poll materializes from
91
     * scratch.
92
     */
93
    public static void bootstrap() {
94
        if (!FeatureFlags.trustStateEnabled()) {
×
95
            return;
×
96
        }
97
        try (RepositoryConnection conn = TripleStore.get().getRepoConnection(TRUST_REPO)) {
×
98
            String query = String.format("""
×
99
                            SELECT ?s WHERE {
100
                              GRAPH <%s> {
101
                                <%s> <%s> ?s .
102
                              }
103
                            } LIMIT 1
104
                            """,
105
                    NPA.GRAPH, NPA.THIS_REPO, NPA_HAS_CURRENT_TRUST_STATE);
106
            try (TupleQueryResult result =
×
107
                         conn.prepareTupleQuery(QueryLanguage.SPARQL, query).evaluate()) {
×
108
                if (!result.hasNext()) {
×
109
                    logger.info("Trust state bootstrap: no current-state pointer yet");
×
110
                    return;
×
111
                }
112
                IRI ptr = (IRI) result.next().getValue("s");
×
113
                String iri = ptr.stringValue();
×
114
                if (!iri.startsWith(NPAT.NAMESPACE)) {
×
115
                    logger.warn("Trust state bootstrap: unexpected pointer IRI {}", iri);
×
116
                    return;
×
117
                }
118
                String hash = iri.substring(NPAT.NAMESPACE.length());
×
119
                if (hash.isEmpty()) {
×
120
                    logger.warn("Trust state bootstrap: pointer IRI has empty hash suffix");
×
121
                    return;
×
122
                }
123
                TrustStateRegistry.get().setCurrentHash(hash);
×
124
                logger.info("Trust state bootstrap: seeded current hash {}", hash);
×
125
            }
×
126
        } catch (Exception ex) {
×
127
            logger.info("Trust state bootstrap: failed to read pointer: {}", ex.toString());
×
128
        }
×
129
    }
×
130

131
    /**
132
     * Called when registry-poll metadata is fetched. Compares the hash to the
133
     * locally-tracked one and, if different, fetches the snapshot and
134
     * materializes it into the {@code trust} repo.
135
     *
136
     * <p>Safe to call with a null/empty hash (older registries don't expose
137
     * trust state) — silently no-op in that case.
138
     *
139
     * @param newTrustStateHash the {@code trustStateHash} reported by the
140
     *                          registry, or null if the registry doesn't
141
     *                          expose one
142
     */
143
    public static void maybeUpdate(String newTrustStateHash) {
144
        if (!FeatureFlags.trustStateEnabled()) {
6!
145
            return;
×
146
        }
147
        if (newTrustStateHash == null || newTrustStateHash.isEmpty()) {
15✔
148
            return;
3✔
149
        }
150
        String current = TrustStateRegistry.get().getCurrentHash().orElse(null);
18✔
151
        if (newTrustStateHash.equals(current)) {
12✔
152
            return;
3✔
153
        }
154

155
        logger.info("Trust state hash change detected: {} -> {}",
9✔
156
                current == null ? "(none)" : current, newTrustStateHash);
15!
157

158
        Optional<TrustStateSnapshot> snapshotOpt = fetchSnapshot(newTrustStateHash);
9✔
159
        if (snapshotOpt.isEmpty()) {
9!
160
            return;
3✔
161
        }
162
        TrustStateSnapshot snapshot = snapshotOpt.get();
×
163

164
        // Integrity check: the URL hash must match what's in the body.
165
        if (!newTrustStateHash.equals(snapshot.trustStateHash())) {
×
166
            logger.warn("Trust state envelope hash mismatch: URL was {}, body says {}",
×
167
                    newTrustStateHash, snapshot.trustStateHash());
×
168
            return;
×
169
        }
170

171
        try {
172
            materialize(snapshot);
×
173
            TrustStateRegistry.get().setCurrentHash(snapshot.trustStateHash());
×
174
            logger.info("Materialized trust state {} (counter={}, accounts={})",
×
175
                    snapshot.trustStateHash(), snapshot.trustStateCounter(),
×
176
                    snapshot.accounts().size());
×
177
        } catch (Exception ex) {
×
178
            logger.warn("Failed to materialize trust state {}: {}",
×
179
                    snapshot.trustStateHash(), ex, ex);
×
180
        }
×
181
    }
×
182

183
    /**
184
     * Fetches and parses the snapshot for the given trust state hash from the
185
     * registry. Returns {@link Optional#empty()} on 404 (the registry has
186
     * pruned this hash) or on any I/O / parse error (logged at INFO).
187
     *
188
     * @param trustStateHash the hash to fetch
189
     * @return the parsed snapshot, or empty if unavailable
190
     */
191
    static Optional<TrustStateSnapshot> fetchSnapshot(String trustStateHash) {
192
        String url = JellyNanopubLoader.registryUrl
9✔
193
                     + "trust-state/" + URLEncoder.encode(trustStateHash, StandardCharsets.UTF_8) + ".json";
9✔
194
        try (var response = httpClient.execute(new HttpGet(url))) {
21✔
195
            int status = response.getStatusLine().getStatusCode();
12✔
196
            if (status == 404) {
9!
197
                logger.info("Trust state snapshot {} returned 404 (pruned by registry); skipping",
12✔
198
                        trustStateHash);
199
                EntityUtils.consumeQuietly(response.getEntity());
9✔
200
                return Optional.empty();
12✔
201
            }
202
            if (status < 200 || status >= 300) {
×
203
                logger.info("Trust state snapshot {} returned HTTP {} ({}); skipping",
×
204
                        trustStateHash, status, response.getStatusLine().getReasonPhrase());
×
205
                EntityUtils.consumeQuietly(response.getEntity());
×
206
                return Optional.empty();
×
207
            }
208
            String body = EntityUtils.toString(response.getEntity(), StandardCharsets.UTF_8);
×
209
            return Optional.of(TrustStateSnapshot.parse(body));
×
210
        } catch (IOException ex) {
12!
211
            logger.info("Failed to fetch trust state snapshot {}: {}", trustStateHash, ex.toString());
×
212
            return Optional.empty();
×
213
        } catch (IllegalArgumentException ex) {
×
214
            logger.info("Failed to parse trust state snapshot {}: {}", trustStateHash, ex.toString());
×
215
            return Optional.empty();
×
216
        }
217
    }
218

219
    /**
220
     * Writes the snapshot's account-state triples into the trust state's named
221
     * graph, writes cross-state metadata into {@code npa:graph}, and swaps the
222
     * current-state pointer — all in one serializable transaction. Idempotent
223
     * on the same hash (re-running just rewrites the same triples).
224
     *
225
     * @param snapshot the snapshot to materialize
226
     */
227
    static void materialize(TrustStateSnapshot snapshot) {
228
        IRI trustStateIri = NPAT.forHash(snapshot.trustStateHash());
×
229

230
        try (RepositoryConnection conn =
231
                     TripleStore.get().getRepoConnection(TRUST_REPO)) {
×
232
            conn.begin(IsolationLevels.SERIALIZABLE);
×
233

234
            // 1. Account-state triples in the trust state's named graph.
235
            // depth / pathCount / ratio / quota may be null (e.g. for status=skipped
236
            // accounts, which were rejected by trust calculation and so don't carry
237
            // these stats). Only emit a triple when the field is present.
238
            for (TrustStateSnapshot.AccountEntry a : snapshot.accounts()) {
×
239
                IRI accountStateIri =
×
240
                        NPAA.forHash(accountStateHash(snapshot.trustStateHash(), a));
×
241
                conn.add(accountStateIri, RDF.TYPE, NPA_ACCOUNT_STATE, trustStateIri);
×
242
                conn.add(accountStateIri, NPA_AGENT,
×
243
                        vf.createIRI(a.agent()), trustStateIri);
×
244
                conn.add(accountStateIri, NPA_PUBKEY,
×
245
                        vf.createLiteral(a.pubkey()), trustStateIri);
×
246
                conn.add(accountStateIri, NPA_TRUST_STATUS,
×
247
                        vf.createIRI(NPA.NAMESPACE, a.status()), trustStateIri);
×
248
                if (a.depth() != null) {
×
249
                    conn.add(accountStateIri, NPA_DEPTH,
×
250
                            vf.createLiteral(a.depth()), trustStateIri);
×
251
                }
252
                if (a.pathCount() != null) {
×
253
                    conn.add(accountStateIri, NPA_PATH_COUNT,
×
254
                            vf.createLiteral(a.pathCount()), trustStateIri);
×
255
                }
256
                if (a.ratio() != null) {
×
257
                    conn.add(accountStateIri, NPA_RATIO,
×
258
                            vf.createLiteral(a.ratio()), trustStateIri);
×
259
                }
260
                if (a.quota() != null) {
×
261
                    conn.add(accountStateIri, NPA_QUOTA,
×
262
                            vf.createLiteral(a.quota()), trustStateIri);
×
263
                }
264
                // Authorizing introduction nanopub (nanopub-registry#117/#118; issue #125
265
                // finding #4). Nullable: absent on snapshots from registries that predate
266
                // the field, so only emit when present. createIRI is fine — the registry
267
                // produces a nanopub trusty URI here, same as the agent IRI above.
268
                if (a.introNanopub() != null && !a.introNanopub().isBlank()) {
×
269
                    conn.add(accountStateIri, NPA_VIA_NANOPUB,
×
270
                            vf.createIRI(a.introNanopub()), trustStateIri);
×
271
                }
272
            }
×
273

274
            // 1b. Canonical foaf:name per agent. The registry stamps each account
275
            // row with the foaf:name + dct:created of its declaring intro
276
            // (see nanopub-registry#113). Per-agent canonical name is whichever
277
            // approved row has the highest ratio (ties → MIN(name) lex). Emitted
278
            // once per agent in the trust-state graph so consumers can read
279
            // ?agent foaf:name ?n directly without a SERVICE join to /repo/full.
280
            for (Map.Entry<String, String> e : resolveCanonicalNames(snapshot).entrySet()) {
×
281
                conn.add(vf.createIRI(e.getKey()), FOAF.NAME,
×
282
                        vf.createLiteral(e.getValue()), trustStateIri);
×
283
            }
×
284

285
            // 2. Cross-state metadata in npa:graph
286
            conn.add(trustStateIri, RDF.TYPE, NPA_TRUST_STATE, NPA.GRAPH);
×
287
            conn.add(trustStateIri, NPA_HAS_TRUST_STATE_HASH,
×
288
                    vf.createLiteral(snapshot.trustStateHash()), NPA.GRAPH);
×
289
            conn.add(trustStateIri, NPA_HAS_TRUST_STATE_COUNTER,
×
290
                    vf.createLiteral(snapshot.trustStateCounter()), NPA.GRAPH);
×
291
            conn.add(trustStateIri, NPA_HAS_CREATED_AT,
×
292
                    vf.createLiteral(snapshot.createdAt().toString(), XSD.DATETIME),
×
293
                    NPA.GRAPH);
294

295
            // 3. Atomic pointer swap
296
            conn.remove(NPA.THIS_REPO, NPA_HAS_CURRENT_TRUST_STATE, null, NPA.GRAPH);
×
297
            conn.add(NPA.THIS_REPO, NPA_HAS_CURRENT_TRUST_STATE, trustStateIri, NPA.GRAPH);
×
298

299
            // 4. Prune any historical trust states beyond the retention window
300
            int pruned = pruneOldStates(conn);
×
301
            if (pruned > 0) {
×
302
                logger.info("Pruned {} trust state(s) beyond retention", pruned);
×
303
            }
304

305
            conn.commit();
×
306
        }
307
    }
×
308

309
    /**
310
     * Removes trust states beyond the retention window: their named-graph
311
     * contents are dropped and their metadata triples in {@code npa:graph}
312
     * are removed. Must be called inside an open transaction on the
313
     * {@code trust} repo. Returns the number of states pruned.
314
     */
315
    private static int pruneOldStates(RepositoryConnection conn) {
316
        int retention = effectiveRetention();
×
317
        // ORDER BY DESC counter, then OFFSET retention → those beyond the keep window.
318
        String query = String.format("""
×
319
                        PREFIX npa: <%s>
320
                        SELECT ?s WHERE {
321
                          GRAPH <%s> {
322
                            ?s a <%s> ; <%s> ?c .
323
                          }
324
                        } ORDER BY DESC(?c) OFFSET %d
325
                        """,
326
                NPA.NAMESPACE, NPA.GRAPH, NPA_TRUST_STATE, NPA_HAS_TRUST_STATE_COUNTER, retention);
×
327

328
        List<IRI> toPrune = new ArrayList<>();
×
329
        try (TupleQueryResult result = conn.prepareTupleQuery(QueryLanguage.SPARQL, query).evaluate()) {
×
330
            while (result.hasNext()) {
×
331
                toPrune.add((IRI) result.next().getValue("s"));
×
332
            }
333
        }
334
        for (IRI old : toPrune) {
×
335
            conn.clear(old);                          // drop the named graph's triples
×
336
            conn.remove(old, null, null, NPA.GRAPH);  // drop its metadata in npa:graph
×
337
        }
×
338
        return toPrune.size();
×
339
    }
340

341
    /**
342
     * Reads {@code TRUST_STATE_LOCAL_RETENTION} from the environment, falling
343
     * back to {@link #DEFAULT_LOCAL_RETENTION}. Values below 1 are coerced
344
     * back to the default with a warning (the plan rejects retention=0).
345
     */
346
    static int effectiveRetention() {
347
        int n = Utils.getEnvInt("TRUST_STATE_LOCAL_RETENTION", DEFAULT_LOCAL_RETENTION);
12✔
348
        if (n < 1) {
9!
349
            logger.warn("TRUST_STATE_LOCAL_RETENTION={} is invalid (must be >= 1); using default {}",
×
350
                    n, DEFAULT_LOCAL_RETENTION);
×
351
            return DEFAULT_LOCAL_RETENTION;
×
352
        }
353
        return n;
6✔
354
    }
355

356
    /**
357
     * Computes the account-state hash for a single entry within a snapshot.
358
     * SHA-256 over {@code trustStateHash + "|" + pubkey + "|" + agent}; the
359
     * trustStateHash is part of the input so the same {@code (pubkey, agent)}
360
     * pair in two snapshots produces two different account-state IRIs.
361
     */
362
    static String accountStateHash(String trustStateHash, TrustStateSnapshot.AccountEntry a) {
363
        String composite = trustStateHash + "|" + a.pubkey() + "|" + a.agent();
21✔
364
        return Hashing.sha256().hashString(composite, StandardCharsets.UTF_8).toString();
18✔
365
    }
366

367
    /**
368
     * Trust-approved status set: rows with one of these {@code npa:trustStatus} values
369
     * are eligible to contribute the canonical agent name. Matches the set used by
370
     * {@code AuthorityResolver.mirrorTrustState}.
371
     */
372
    private static final Set<String> APPROVED_STATUSES = Set.of("loaded", "toLoad");
15✔
373

374
    /**
375
     * Per-agent canonical name resolution. Returns a map from agent IRI to its
376
     * canonical {@code foaf:name} literal, derived from the snapshot's per-account
377
     * {@code name} field.
378
     *
379
     * <p>Policy: among an agent's account rows whose {@code status} is approved
380
     * ({@code loaded} or {@code toLoad}) and whose {@code ratio} and {@code name}
381
     * are both non-null, pick the row with the highest {@code ratio}. Ties break
382
     * on lex-min {@code name} for determinism across rebuilds. Agents with no
383
     * qualifying row are simply absent from the result map (no name emitted).
384
     *
385
     * <p>Per-{@code (agent, pubkey)} resolution (the latest declaring intro
386
     * supplies that row's {@code name}) lives in the registry; this layer only
387
     * folds across keys.
388
     */
389
    static Map<String, String> resolveCanonicalNames(TrustStateSnapshot snapshot) {
390
        Map<String, TrustStateSnapshot.AccountEntry> chosen = new HashMap<>();
12✔
391
        for (TrustStateSnapshot.AccountEntry a : snapshot.accounts()) {
33✔
392
            if (!APPROVED_STATUSES.contains(a.status())) {
15✔
393
                continue;
3✔
394
            }
395
            if (a.name() == null || a.ratio() == null) {
18!
396
                continue;
×
397
            }
398
            TrustStateSnapshot.AccountEntry incumbent = chosen.get(a.agent());
18✔
399
            if (incumbent == null
9✔
400
                || a.ratio() > incumbent.ratio()
24✔
401
                || (a.ratio().equals(incumbent.ratio())
18!
402
                    && a.name().compareTo(incumbent.name()) < 0)) {
15!
403
                chosen.put(a.agent(), a);
18✔
404
            }
405
        }
3✔
406
        Map<String, String> result = new HashMap<>(chosen.size());
18✔
407
        for (Map.Entry<String, TrustStateSnapshot.AccountEntry> e : chosen.entrySet()) {
33✔
408
            result.put(e.getKey(), e.getValue().name());
30✔
409
        }
3✔
410
        return result;
6✔
411
    }
412

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