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

knowledgepixels / nanopub-query / 28166835599

25 Jun 2026 11:27AM UTC coverage: 61.063% (-0.2%) from 61.236%
28166835599

push

github

web-flow
Merge pull request #133 from knowledgepixels/fix/issue-125-mirror-intro-provenance

fix(trust): mirror authorizing introNanopub onto AccountState (#125 finding #4)

541 of 982 branches covered (55.09%)

Branch coverage included in aggregate %.

1584 of 2498 relevant lines covered (63.41%)

9.64 hits per line

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

70.75
src/main/java/com/knowledgepixels/query/TrustStateSnapshot.java
1
package com.knowledgepixels.query;
2

3
import java.time.Instant;
4
import java.time.ZonedDateTime;
5
import java.util.ArrayList;
6
import java.util.Collections;
7
import java.util.List;
8

9
import io.vertx.core.json.JsonArray;
10
import io.vertx.core.json.JsonObject;
11

12
/**
13
 * Parsed envelope of a {@code /trust-state/<hash>.json} response from the
14
 * registry. Immutable; produced by {@link #parse(String)}.
15
 *
16
 * <p>The {@code trustStateCounter} field arrives as MongoDB extended JSON
17
 * (e.g. {@code {"$numberLong": "1"}}) when the registry's serializer chooses
18
 * to wrap a long. The parser handles either wrapped or plain numeric form.
19
 *
20
 * <p>The {@code createdAt} field uses Java's {@code ZonedDateTime.toString()}
21
 * shape — ISO-8601 plus an optional {@code [Etc/UTC]} zone bracket — which
22
 * {@link ZonedDateTime#parse(CharSequence)} reads natively.
23
 *
24
 * @param trustStateHash the hash advertised by the registry for this snapshot
25
 * @param trustStateCounter monotonic counter; matches the registry's value
26
 * @param createdAt when the registry committed this snapshot
27
 * @param accounts one entry per non-{@code "$"} account in the trust graph
28
 */
29
public record TrustStateSnapshot(
45✔
30
        String trustStateHash,
31
        long trustStateCounter,
32
        Instant createdAt,
33
        List<AccountEntry> accounts
34
) {
35

36
    /**
37
     * One account in a trust state snapshot. Mirrors the fields the registry
38
     * exposes; consumers (e.g. authority queries) decide which {@code status}
39
     * values count as "approved".
40
     *
41
     * <p>{@code pathCount}, {@code ratio}, and {@code quota} can be {@code null}
42
     * for accounts with {@code status == "skipped"} (trust calculation rejected
43
     * them, so those stats aren't meaningful). {@code name} and {@code nameCreatedAt}
44
     * are {@code null} when the declaring intro nanopub asserted no
45
     * {@code foaf:name} on the agent (or when running against a registry that
46
     * predates the field — additive non-breaking schema). Boxed types preserve
47
     * the absence.
48
     *
49
     * @param pubkey hex-encoded public key
50
     * @param agent agent IRI (typically an ORCID, but any IRI is allowed)
51
     * @param status one of the registry's {@code EntryStatus} values
52
     *               (e.g. {@code "loaded"}, {@code "toLoad"}, {@code "skipped"})
53
     * @param depth steps from the trust seed
54
     * @param pathCount number of independent trust paths, or {@code null} for skipped accounts
55
     * @param ratio aggregated trust score, or {@code null} for skipped accounts
56
     * @param quota allocated upload quota, or {@code null} for skipped accounts
57
     * @param name {@code foaf:name} from the declaring intro nanopub, or {@code null} if absent
58
     * @param nameCreatedAt {@code dct:created} of the declaring intro, or {@code null} if absent.
59
     *                      Used to break ties when multiple intros declare the same {@code (agent, pubkey)};
60
     *                      consumers picking one canonical name per agent across keys typically use
61
     *                      {@code MAX(ratio)} with this as a secondary tiebreaker.
62
     * @param introNanopub IRI of the authorizing introduction nanopub for this {@code (agent, pubkey)}
63
     *                     (the intro carrying the key declaration; nanopub-registry#117/#118), or
64
     *                     {@code null} when running against a registry whose snapshot predates the
65
     *                     field — additive non-breaking schema, so absence must be tolerated during
66
     *                     the fleet rollout. Tracks the same name-winning intro as {@code name}.
67
     */
68
    public record AccountEntry(
99✔
69
            String pubkey,
70
            String agent,
71
            String status,
72
            Integer depth,
73
            Integer pathCount,
74
            Double ratio,
75
            Long quota,
76
            String name,
77
            Instant nameCreatedAt,
78
            String introNanopub
79
    ) {
80
        /**
81
         * Back-compat constructor without {@code introNanopub} (defaults to {@code null}) —
82
         * the field is additive (nanopub-registry#117/#118). Convenient for call sites that
83
         * predate it; production parsing always uses the canonical constructor.
84
         */
85
        public AccountEntry(String pubkey, String agent, String status, Integer depth,
86
                            Integer pathCount, Double ratio, Long quota, String name,
87
                            Instant nameCreatedAt) {
88
            this(pubkey, agent, status, depth, pathCount, ratio, quota, name, nameCreatedAt, null);
36✔
89
        }
3✔
90
    }
91

92
    /**
93
     * Parses a {@code /trust-state/<hash>.json} envelope from its JSON
94
     * serialization. Throws {@link IllegalArgumentException} if the JSON is
95
     * malformed or missing required fields.
96
     *
97
     * @param json the response body as a string
98
     * @return the parsed snapshot
99
     * @throws IllegalArgumentException if parsing fails
100
     */
101
    public static TrustStateSnapshot parse(String json) {
102
        JsonObject obj;
103
        try {
104
            obj = new JsonObject(json);
15✔
105
        } catch (Exception ex) {
3✔
106
            throw new IllegalArgumentException("Trust state envelope is not valid JSON", ex);
18✔
107
        }
3✔
108

109
        String hash = requireString(obj, "trustStateHash");
12✔
110
        long counter = unwrapLong(obj, "trustStateCounter");
12✔
111

112
        String rawCreatedAt = requireString(obj, "createdAt");
12✔
113
        Instant createdAt;
114
        try {
115
            createdAt = ZonedDateTime.parse(rawCreatedAt).toInstant();
12✔
116
        } catch (Exception ex) {
3✔
117
            throw new IllegalArgumentException(
21✔
118
                    "Cannot parse createdAt as ZonedDateTime: " + rawCreatedAt, ex);
119
        }
3✔
120

121
        JsonArray accountsArray = obj.getJsonArray("accounts");
12✔
122
        if (accountsArray == null) {
6✔
123
            throw new IllegalArgumentException("Trust state envelope is missing 'accounts' array");
15✔
124
        }
125
        List<AccountEntry> accounts = new ArrayList<>(accountsArray.size());
18✔
126
        for (int i = 0; i < accountsArray.size(); i++) {
24✔
127
            JsonObject a;
128
            try {
129
                a = accountsArray.getJsonObject(i);
12✔
130
            } catch (ClassCastException ex) {
×
131
                throw new IllegalArgumentException(
×
132
                        "Trust state account entry " + i + " is not a JSON object", ex);
133
            }
3✔
134
            accounts.add(new AccountEntry(
21✔
135
                    requireString(a, "pubkey"),
9✔
136
                    requireString(a, "agent"),
9✔
137
                    requireString(a, "status"),
9✔
138
                    a.getInteger("depth"),
9✔
139
                    a.getInteger("pathCount"),
9✔
140
                    a.getDouble("ratio"),
9✔
141
                    unwrapLongNullable(a, "quota"),
9✔
142
                    a.getString("name"),
9✔
143
                    unwrapDateNullable(a, "nameCreatedAt"),
9✔
144
                    a.getString("introNanopub")
6✔
145
            ));
146
        }
147

148
        return new TrustStateSnapshot(hash, counter, createdAt,
21✔
149
                Collections.unmodifiableList(accounts));
6✔
150
    }
151

152
    private static String requireString(JsonObject obj, String key) {
153
        String s = obj.getString(key);
12✔
154
        if (s == null) {
6✔
155
            throw new IllegalArgumentException("Trust state envelope is missing required field: " + key);
18✔
156
        }
157
        return s;
6✔
158
    }
159

160
    /**
161
     * Reads a required long-typed field, transparently handling MongoDB
162
     * extended JSON ({@code {"$numberLong": "..."}}) as well as plain numeric
163
     * or string forms. Throws if the field is missing or null.
164
     */
165
    private static long unwrapLong(JsonObject obj, String key) {
166
        Long v = unwrapLongNullable(obj, key);
12✔
167
        if (v == null) {
6!
168
            throw new IllegalArgumentException("Trust state envelope is missing required field: " + key);
×
169
        }
170
        return v;
9✔
171
    }
172

173
    /**
174
     * Reads an optional date-typed field. Handles MongoDB extended JSON
175
     * ({@code {"$date": "..."}} or {@code {"$date": {"$numberLong": "..."}}}),
176
     * plain ISO-8601 strings, and JSON {@code null}/missing. Returns {@code null}
177
     * for any of those last cases so the field is fully optional — consumers
178
     * working with snapshots from a registry that predates the {@code nameCreatedAt}
179
     * field still parse cleanly.
180
     */
181
    private static Instant unwrapDateNullable(JsonObject obj, String key) {
182
        Object v = obj.getValue(key);
12✔
183
        if (v == null) return null;
12✔
184
        if (v instanceof JsonObject j) {
18✔
185
            Object dateVal = j.getValue("$date");
12✔
186
            if (dateVal instanceof String s) {
18!
187
                try {
188
                    return Instant.parse(s);
9✔
189
                } catch (Exception ex) {
×
190
                    throw new IllegalArgumentException("Cannot parse " + key + " as ISO-8601 instant: " + s, ex);
×
191
                }
192
            }
193
            if (dateVal instanceof JsonObject inner) {
×
194
                String numberLong = inner.getString("$numberLong");
×
195
                if (numberLong != null) return Instant.ofEpochMilli(Long.parseLong(numberLong));
×
196
            }
197
            if (dateVal instanceof Number n) return Instant.ofEpochMilli(n.longValue());
×
198
            throw new IllegalArgumentException("Cannot unwrap " + key + " from extended JSON $date: " + j);
×
199
        }
200
        if (v instanceof String s) {
18!
201
            try {
202
                return Instant.parse(s);
9✔
203
            } catch (Exception ex) {
×
204
                throw new IllegalArgumentException("Cannot parse " + key + " as ISO-8601 instant: " + s, ex);
×
205
            }
206
        }
207
        if (v instanceof Number n) return Instant.ofEpochMilli(n.longValue());
×
208
        throw new IllegalArgumentException("Cannot unwrap " + key + " as date: " + v);
×
209
    }
210

211
    /**
212
     * Reads an optional long-typed field, returning {@code null} if the field
213
     * is absent or its value is JSON {@code null}. Same extended-JSON handling
214
     * as {@link #unwrapLong(JsonObject, String)}.
215
     */
216
    private static Long unwrapLongNullable(JsonObject obj, String key) {
217
        Object v = obj.getValue(key);
12✔
218
        if (v == null) return null;
12✔
219
        if (v instanceof Number n) return n.longValue();
30✔
220
        if (v instanceof JsonObject j) {
18!
221
            String s = j.getString("$numberLong");
12✔
222
            if (s != null) return Long.parseLong(s);
18!
223
        }
224
        if (v instanceof String s) return Long.parseLong(s);
×
225
        throw new IllegalArgumentException("Cannot unwrap " + key + " as long: " + v);
×
226
    }
227

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