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

knowledgepixels / nanopub-query / 25038365118

28 Apr 2026 06:51AM UTC coverage: 56.362% (-0.4%) from 56.712%
25038365118

push

github

web-flow
Merge pull request #92 from knowledgepixels/chore/dependency-bumps-2026-04

chore(deps): bump nanopub 1.86.2→1.87.1 and rdf4j 5.3.0→5.3.1

425 of 842 branches covered (50.48%)

Branch coverage included in aggregate %.

1183 of 2011 relevant lines covered (58.83%)

9.0 hits per line

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

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

3
import java.io.IOException;
4
import java.net.URLEncoder;
5
import java.nio.charset.StandardCharsets;
6
import java.util.ArrayList;
7
import java.util.HashMap;
8
import java.util.List;
9
import java.util.Map;
10
import java.util.Optional;
11
import java.util.Set;
12

13
import org.apache.http.client.methods.HttpGet;
14
import org.apache.http.impl.client.CloseableHttpClient;
15
import org.apache.http.impl.client.HttpClientBuilder;
16
import org.apache.http.util.EntityUtils;
17
import org.eclipse.rdf4j.common.transaction.IsolationLevels;
18
import org.eclipse.rdf4j.model.IRI;
19
import org.eclipse.rdf4j.model.ValueFactory;
20
import org.eclipse.rdf4j.model.impl.SimpleValueFactory;
21
import org.eclipse.rdf4j.model.vocabulary.FOAF;
22
import org.eclipse.rdf4j.model.vocabulary.RDF;
23
import org.eclipse.rdf4j.model.vocabulary.XSD;
24
import org.eclipse.rdf4j.query.QueryLanguage;
25
import org.eclipse.rdf4j.query.TupleQueryResult;
26
import org.eclipse.rdf4j.repository.RepositoryConnection;
27
import org.nanopub.vocabulary.NPA;
28
import org.slf4j.Logger;
29
import org.slf4j.LoggerFactory;
30

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

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

49
    private static final Logger log = LoggerFactory.getLogger(TrustStateLoader.class);
9✔
50

51
    /** Local name of the repository that holds all mirrored trust states. */
52
    static final String TRUST_REPO = "trust";
53

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

57
    private static final ValueFactory vf = SimpleValueFactory.getInstance();
6✔
58

59
    // Local extensions to the upstream NPA vocabulary (terms used only on the
60
    // consumer side). Defined here rather than in a vocab class because they're
61
    // strictly internal to the trust-state mirroring code.
62
    private static final IRI NPA_TRUST_STATE = vf.createIRI(NPA.NAMESPACE, "TrustState");
15✔
63
    private static final IRI NPA_ACCOUNT_STATE = vf.createIRI(NPA.NAMESPACE, "AccountState");
15✔
64
    private static final IRI NPA_HAS_TRUST_STATE_HASH = vf.createIRI(NPA.NAMESPACE, "hasTrustStateHash");
15✔
65
    private static final IRI NPA_HAS_TRUST_STATE_COUNTER = vf.createIRI(NPA.NAMESPACE, "hasTrustStateCounter");
15✔
66
    private static final IRI NPA_HAS_CREATED_AT = vf.createIRI(NPA.NAMESPACE, "hasCreatedAt");
15✔
67
    private static final IRI NPA_HAS_CURRENT_TRUST_STATE = vf.createIRI(NPA.NAMESPACE, "hasCurrentTrustState");
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_DEPTH = vf.createIRI(NPA.NAMESPACE, "depth");
15✔
72
    private static final IRI NPA_PATH_COUNT = vf.createIRI(NPA.NAMESPACE, "pathCount");
15✔
73
    private static final IRI NPA_RATIO = vf.createIRI(NPA.NAMESPACE, "ratio");
15✔
74
    private static final IRI NPA_QUOTA = vf.createIRI(NPA.NAMESPACE, "quota");
15✔
75

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

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

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

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

148
        log.info("Trust state hash change detected: {} -> {}",
9✔
149
                current == null ? "(none)" : current, newTrustStateHash);
15!
150

151
        Optional<TrustStateSnapshot> snapshotOpt = fetchSnapshot(newTrustStateHash);
9✔
152
        if (snapshotOpt.isEmpty()) return;
12!
153
        TrustStateSnapshot snapshot = snapshotOpt.get();
×
154

155
        // Integrity check: the URL hash must match what's in the body.
156
        if (!newTrustStateHash.equals(snapshot.trustStateHash())) {
×
157
            log.warn("Trust state envelope hash mismatch: URL was {}, body says {}",
×
158
                    newTrustStateHash, snapshot.trustStateHash());
×
159
            return;
×
160
        }
161

162
        try {
163
            materialize(snapshot);
×
164
            TrustStateRegistry.get().setCurrentHash(snapshot.trustStateHash());
×
165
            log.info("Materialized trust state {} (counter={}, accounts={})",
×
166
                    snapshot.trustStateHash(), snapshot.trustStateCounter(),
×
167
                    snapshot.accounts().size());
×
168
        } catch (Exception ex) {
×
169
            log.warn("Failed to materialize trust state {}: {}",
×
170
                    snapshot.trustStateHash(), ex.toString(), ex);
×
171
        }
×
172
    }
×
173

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

210
    /**
211
     * Writes the snapshot's account-state triples into the trust state's named
212
     * graph, writes cross-state metadata into {@code npa:graph}, and swaps the
213
     * current-state pointer — all in one serializable transaction. Idempotent
214
     * on the same hash (re-running just rewrites the same triples).
215
     *
216
     * @param snapshot the snapshot to materialize
217
     */
218
    static void materialize(TrustStateSnapshot snapshot) {
219
        IRI trustStateIri = NPAT.forHash(snapshot.trustStateHash());
×
220

221
        try (RepositoryConnection conn =
222
                     TripleStore.get().getRepoConnection(TRUST_REPO)) {
×
223
            conn.begin(IsolationLevels.SERIALIZABLE);
×
224

225
            // 1. Account-state triples in the trust state's named graph.
226
            // depth / pathCount / ratio / quota may be null (e.g. for status=skipped
227
            // accounts, which were rejected by trust calculation and so don't carry
228
            // these stats). Only emit a triple when the field is present.
229
            for (TrustStateSnapshot.AccountEntry a : snapshot.accounts()) {
×
230
                IRI accountStateIri =
×
231
                        NPAA.forHash(accountStateHash(snapshot.trustStateHash(), a));
×
232
                conn.add(accountStateIri, RDF.TYPE, NPA_ACCOUNT_STATE, trustStateIri);
×
233
                conn.add(accountStateIri, NPA_AGENT,
×
234
                        vf.createIRI(a.agent()), trustStateIri);
×
235
                conn.add(accountStateIri, NPA_PUBKEY,
×
236
                        vf.createLiteral(a.pubkey()), trustStateIri);
×
237
                conn.add(accountStateIri, NPA_TRUST_STATUS,
×
238
                        vf.createIRI(NPA.NAMESPACE, a.status()), trustStateIri);
×
239
                if (a.depth() != null) {
×
240
                    conn.add(accountStateIri, NPA_DEPTH,
×
241
                            vf.createLiteral(a.depth()), trustStateIri);
×
242
                }
243
                if (a.pathCount() != null) {
×
244
                    conn.add(accountStateIri, NPA_PATH_COUNT,
×
245
                            vf.createLiteral(a.pathCount()), trustStateIri);
×
246
                }
247
                if (a.ratio() != null) {
×
248
                    conn.add(accountStateIri, NPA_RATIO,
×
249
                            vf.createLiteral(a.ratio()), trustStateIri);
×
250
                }
251
                if (a.quota() != null) {
×
252
                    conn.add(accountStateIri, NPA_QUOTA,
×
253
                            vf.createLiteral(a.quota()), trustStateIri);
×
254
                }
255
            }
×
256

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

268
            // 2. Cross-state metadata in npa:graph
269
            conn.add(trustStateIri, RDF.TYPE, NPA_TRUST_STATE, NPA.GRAPH);
×
270
            conn.add(trustStateIri, NPA_HAS_TRUST_STATE_HASH,
×
271
                    vf.createLiteral(snapshot.trustStateHash()), NPA.GRAPH);
×
272
            conn.add(trustStateIri, NPA_HAS_TRUST_STATE_COUNTER,
×
273
                    vf.createLiteral(snapshot.trustStateCounter()), NPA.GRAPH);
×
274
            conn.add(trustStateIri, NPA_HAS_CREATED_AT,
×
275
                    vf.createLiteral(snapshot.createdAt().toString(), XSD.DATETIME),
×
276
                    NPA.GRAPH);
277

278
            // 3. Atomic pointer swap
279
            conn.remove(NPA.THIS_REPO, NPA_HAS_CURRENT_TRUST_STATE, null, NPA.GRAPH);
×
280
            conn.add(NPA.THIS_REPO, NPA_HAS_CURRENT_TRUST_STATE, trustStateIri, NPA.GRAPH);
×
281

282
            // 4. Prune any historical trust states beyond the retention window
283
            int pruned = pruneOldStates(conn);
×
284
            if (pruned > 0) {
×
285
                log.info("Pruned {} trust state(s) beyond retention", pruned);
×
286
            }
287

288
            conn.commit();
×
289
        }
290
    }
×
291

292
    /**
293
     * Removes trust states beyond the retention window: their named-graph
294
     * contents are dropped and their metadata triples in {@code npa:graph}
295
     * are removed. Must be called inside an open transaction on the
296
     * {@code trust} repo. Returns the number of states pruned.
297
     */
298
    private static int pruneOldStates(RepositoryConnection conn) {
299
        int retention = effectiveRetention();
×
300
        // ORDER BY DESC counter, then OFFSET retention → those beyond the keep window.
301
        String query = String.format("""
×
302
                PREFIX npa: <%s>
303
                SELECT ?s WHERE {
304
                  GRAPH <%s> {
305
                    ?s a <%s> ; <%s> ?c .
306
                  }
307
                } ORDER BY DESC(?c) OFFSET %d
308
                """,
309
                NPA.NAMESPACE, NPA.GRAPH, NPA_TRUST_STATE, NPA_HAS_TRUST_STATE_COUNTER, retention);
×
310

311
        List<IRI> toPrune = new ArrayList<>();
×
312
        try (TupleQueryResult result = conn.prepareTupleQuery(QueryLanguage.SPARQL, query).evaluate()) {
×
313
            while (result.hasNext()) {
×
314
                toPrune.add((IRI) result.next().getValue("s"));
×
315
            }
316
        }
317
        for (IRI old : toPrune) {
×
318
            conn.clear(old);                          // drop the named graph's triples
×
319
            conn.remove(old, null, null, NPA.GRAPH);  // drop its metadata in npa:graph
×
320
        }
×
321
        return toPrune.size();
×
322
    }
323

324
    /**
325
     * Reads {@code TRUST_STATE_LOCAL_RETENTION} from the environment, falling
326
     * back to {@link #DEFAULT_LOCAL_RETENTION}. Values below 1 are coerced
327
     * back to the default with a warning (the plan rejects retention=0).
328
     */
329
    static int effectiveRetention() {
330
        int n = Utils.getEnvInt("TRUST_STATE_LOCAL_RETENTION", DEFAULT_LOCAL_RETENTION);
12✔
331
        if (n < 1) {
9!
332
            log.warn("TRUST_STATE_LOCAL_RETENTION={} is invalid (must be >= 1); using default {}",
×
333
                    n, DEFAULT_LOCAL_RETENTION);
×
334
            return DEFAULT_LOCAL_RETENTION;
×
335
        }
336
        return n;
6✔
337
    }
338

339
    /**
340
     * Computes the account-state hash for a single entry within a snapshot.
341
     * SHA-256 over {@code trustStateHash + "|" + pubkey + "|" + agent}; the
342
     * trustStateHash is part of the input so the same {@code (pubkey, agent)}
343
     * pair in two snapshots produces two different account-state IRIs.
344
     */
345
    static String accountStateHash(String trustStateHash, TrustStateSnapshot.AccountEntry a) {
346
        String composite = trustStateHash + "|" + a.pubkey() + "|" + a.agent();
21✔
347
        return Hashing.sha256().hashString(composite, StandardCharsets.UTF_8).toString();
18✔
348
    }
349

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

355
    /**
356
     * Per-agent canonical name resolution. Returns a map from agent IRI to its
357
     * canonical {@code foaf:name} literal, derived from the snapshot's per-account
358
     * {@code name} field.
359
     *
360
     * <p>Policy: among an agent's account rows whose {@code status} is approved
361
     * ({@code loaded} or {@code toLoad}) and whose {@code ratio} and {@code name}
362
     * are both non-null, pick the row with the highest {@code ratio}. Ties break
363
     * on lex-min {@code name} for determinism across rebuilds. Agents with no
364
     * qualifying row are simply absent from the result map (no name emitted).
365
     *
366
     * <p>Per-{@code (agent, pubkey)} resolution (the latest declaring intro
367
     * supplies that row's {@code name}) lives in the registry; this layer only
368
     * folds across keys.
369
     */
370
    static Map<String, String> resolveCanonicalNames(TrustStateSnapshot snapshot) {
371
        Map<String, TrustStateSnapshot.AccountEntry> chosen = new HashMap<>();
12✔
372
        for (TrustStateSnapshot.AccountEntry a : snapshot.accounts()) {
33✔
373
            if (!APPROVED_STATUSES.contains(a.status())) continue;
18✔
374
            if (a.name() == null || a.ratio() == null) continue;
18!
375
            TrustStateSnapshot.AccountEntry incumbent = chosen.get(a.agent());
18✔
376
            if (incumbent == null
9✔
377
                    || a.ratio() > incumbent.ratio()
24✔
378
                    || (a.ratio().equals(incumbent.ratio())
18!
379
                            && a.name().compareTo(incumbent.name()) < 0)) {
15!
380
                chosen.put(a.agent(), a);
18✔
381
            }
382
        }
3✔
383
        Map<String, String> result = new HashMap<>(chosen.size());
18✔
384
        for (Map.Entry<String, TrustStateSnapshot.AccountEntry> e : chosen.entrySet()) {
33✔
385
            result.put(e.getKey(), e.getValue().name());
30✔
386
        }
3✔
387
        return result;
6✔
388
    }
389

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