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

knowledgepixels / nanopub-query / 24523163064

16 Apr 2026 04:59PM UTC coverage: 63.599% (-0.6%) from 64.192%
24523163064

push

github

tkuhn
doc: rename trust-state plan to design now that #65 is shipped

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

281 of 488 branches covered (57.58%)

Branch coverage included in aggregate %.

790 of 1196 relevant lines covered (66.05%)

9.63 hits per line

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

24.38
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.List;
8
import java.util.Optional;
9

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

27
import com.google.common.hash.Hashing;
28
import com.knowledgepixels.query.vocabulary.NPAA;
29
import com.knowledgepixels.query.vocabulary.NPAT;
30

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

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

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

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

53
    private static final ValueFactory vf = SimpleValueFactory.getInstance();
6✔
54

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

72
    private static final CloseableHttpClient httpClient =
3✔
73
            HttpClientBuilder.create().setDefaultRequestConfig(Utils.getHttpRequestConfig()).build();
15✔
74

75
    private TrustStateLoader() {
76
    }  // no instances
77

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

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

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

145
        Optional<TrustStateSnapshot> snapshotOpt = fetchSnapshot(newTrustStateHash);
9✔
146
        if (snapshotOpt.isEmpty()) return;
12!
147
        TrustStateSnapshot snapshot = snapshotOpt.get();
×
148

149
        // Integrity check: the URL hash must match what's in the body.
150
        if (!newTrustStateHash.equals(snapshot.trustStateHash())) {
×
151
            log.warn("Trust state envelope hash mismatch: URL was {}, body says {}",
×
152
                    newTrustStateHash, snapshot.trustStateHash());
×
153
            return;
×
154
        }
155

156
        try {
157
            materialize(snapshot);
×
158
            TrustStateRegistry.get().setCurrentHash(snapshot.trustStateHash());
×
159
            log.info("Materialized trust state {} (counter={}, accounts={})",
×
160
                    snapshot.trustStateHash(), snapshot.trustStateCounter(),
×
161
                    snapshot.accounts().size());
×
162
        } catch (Exception ex) {
×
163
            log.warn("Failed to materialize trust state {}: {}",
×
164
                    snapshot.trustStateHash(), ex.toString(), ex);
×
165
        }
×
166
    }
×
167

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

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

215
        try (RepositoryConnection conn =
216
                     TripleStore.get().getRepoConnection(TRUST_REPO)) {
×
217
            conn.begin(IsolationLevels.SERIALIZABLE);
×
218

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

251
            // 2. Cross-state metadata in npa:graph
252
            conn.add(trustStateIri, RDF.TYPE, NPA_TRUST_STATE, NPA.GRAPH);
×
253
            conn.add(trustStateIri, NPA_HAS_TRUST_STATE_HASH,
×
254
                    vf.createLiteral(snapshot.trustStateHash()), NPA.GRAPH);
×
255
            conn.add(trustStateIri, NPA_HAS_TRUST_STATE_COUNTER,
×
256
                    vf.createLiteral(snapshot.trustStateCounter()), NPA.GRAPH);
×
257
            conn.add(trustStateIri, NPA_HAS_CREATED_AT,
×
258
                    vf.createLiteral(snapshot.createdAt().toString(), XSD.DATETIME),
×
259
                    NPA.GRAPH);
260

261
            // 3. Atomic pointer swap
262
            conn.remove(NPA.THIS_REPO, NPA_HAS_CURRENT_TRUST_STATE, null, NPA.GRAPH);
×
263
            conn.add(NPA.THIS_REPO, NPA_HAS_CURRENT_TRUST_STATE, trustStateIri, NPA.GRAPH);
×
264

265
            // 4. Prune any historical trust states beyond the retention window
266
            int pruned = pruneOldStates(conn);
×
267
            if (pruned > 0) {
×
268
                log.info("Pruned {} trust state(s) beyond retention", pruned);
×
269
            }
270

271
            conn.commit();
×
272
        }
273
    }
×
274

275
    /**
276
     * Removes trust states beyond the retention window: their named-graph
277
     * contents are dropped and their metadata triples in {@code npa:graph}
278
     * are removed. Must be called inside an open transaction on the
279
     * {@code trust} repo. Returns the number of states pruned.
280
     */
281
    private static int pruneOldStates(RepositoryConnection conn) {
282
        int retention = effectiveRetention();
×
283
        // ORDER BY DESC counter, then OFFSET retention → those beyond the keep window.
284
        String query = String.format("""
×
285
                PREFIX npa: <%s>
286
                SELECT ?s WHERE {
287
                  GRAPH <%s> {
288
                    ?s a <%s> ; <%s> ?c .
289
                  }
290
                } ORDER BY DESC(?c) OFFSET %d
291
                """,
292
                NPA.NAMESPACE, NPA.GRAPH, NPA_TRUST_STATE, NPA_HAS_TRUST_STATE_COUNTER, retention);
×
293

294
        List<IRI> toPrune = new ArrayList<>();
×
295
        try (TupleQueryResult result = conn.prepareTupleQuery(QueryLanguage.SPARQL, query).evaluate()) {
×
296
            while (result.hasNext()) {
×
297
                toPrune.add((IRI) result.next().getValue("s"));
×
298
            }
299
        }
300
        for (IRI old : toPrune) {
×
301
            conn.clear(old);                          // drop the named graph's triples
×
302
            conn.remove(old, null, null, NPA.GRAPH);  // drop its metadata in npa:graph
×
303
        }
×
304
        return toPrune.size();
×
305
    }
306

307
    /**
308
     * Reads {@code TRUST_STATE_LOCAL_RETENTION} from the environment, falling
309
     * back to {@link #DEFAULT_LOCAL_RETENTION}. Values below 1 are coerced
310
     * back to the default with a warning (the plan rejects retention=0).
311
     */
312
    static int effectiveRetention() {
313
        int n = Utils.getEnvInt("TRUST_STATE_LOCAL_RETENTION", DEFAULT_LOCAL_RETENTION);
12✔
314
        if (n < 1) {
9!
315
            log.warn("TRUST_STATE_LOCAL_RETENTION={} is invalid (must be >= 1); using default {}",
×
316
                    n, DEFAULT_LOCAL_RETENTION);
×
317
            return DEFAULT_LOCAL_RETENTION;
×
318
        }
319
        return n;
6✔
320
    }
321

322
    /**
323
     * Computes the account-state hash for a single entry within a snapshot.
324
     * SHA-256 over {@code trustStateHash + "|" + pubkey + "|" + agent}; the
325
     * trustStateHash is part of the input so the same {@code (pubkey, agent)}
326
     * pair in two snapshots produces two different account-state IRIs.
327
     */
328
    static String accountStateHash(String trustStateHash, TrustStateSnapshot.AccountEntry a) {
329
        String composite = trustStateHash + "|" + a.pubkey() + "|" + a.agent();
21✔
330
        return Hashing.sha256().hashString(composite, StandardCharsets.UTF_8).toString();
18✔
331
    }
332

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