choir_node/profile.rs
1//! Who an actor is, from what the log already says about them (D63).
2//!
3//! Nothing here is new state. Every number is counted out of the same
4//! `/api/view` body the caller could fetch themselves; the point is that
5//! nobody does, because the answer to "should I trust this reviewer" is
6//! spread across four sections of a document that is mostly about
7//! something else.
8//!
9//! **It derives from the view the caller may already see, never from the
10//! raw one.** That is the whole ACL story: a profile cannot disclose a
11//! change, review or check that `/api/view` would have withheld, because
12//! it is looking at the withheld copy. A second filter here would be a
13//! second thing to keep right, and the first time the two disagreed the
14//! profile would be the one that leaked.
15//!
16//! D24's Sybil resistance wants key age, vouches, scoped grants and
17//! bonds. Two of the four are persisted now — key age as
18//! [`KeyBinding::bound_at`], and the vouch graph as [`View::vouches`]
19//! (D65) — and this reads both. Bonds do not exist. Scoped grants do,
20//! as of D66, but not in a form this can report: a time-locked grant
21//! lives in the ACL file and the account store, never in the log, so it
22//! is invisible to a reading of the view and stays named here rather
23//! than counted.
24//!
25//! There is deliberately no score. Two inputs are not more scoreable
26//! than one: any weighting of age against vouches is a claim about how
27//! much an endorsement is worth, which nothing here has measured, and a
28//! number would read as that measurement while being a guess. The
29//! reader gets the inputs.
30//!
31//! **Vouches are operator-scoped, and the profile says so.** They are
32//! edges between operator identities, so a page about the channel
33//! `ops/agent` shows what was vouched to `ops` — which is the truth, and
34//! is why the operator is named in the section rather than left for a
35//! reader to infer from a name that does not match the heading.
36//!
37//! [`KeyBinding::bound_at`]: choir_view::KeyBinding::bound_at
38//! [`View::vouches`]: choir_view::View::vouches
39//!
40//! # Examples
41//!
42//! ```
43//! // Shaped the way `/api/view` really answers, which is the whole
44//! // reason this example is worth reading: `next_seq` lives inside
45//! // `log`, and an example that put it anywhere else would document a
46//! // response no node sends.
47//! let view = serde_json::json!({
48//! "log": { "next_seq": 100 },
49//! "bindings": {
50//! "b3:aa": { "operator": "ops", "channel": "alice", "bound_at": 40 }
51//! },
52//! "changes": { "c1": { "owner": "alice" } },
53//! "reviews": {},
54//! "checks": {},
55//! // Subject -> voucher, the direction the fold stores. `bob`
56//! // vouches for `alice`, and `alice` does not vouch back.
57//! "vouches": { "alice": { "bob": { "at": 55, "note": "shipped the parser" } } },
58//! });
59//! let profile = choir_node::profile::of(&view, "alice");
60//! assert_eq!(profile["channel"], "alice");
61//! assert_eq!(profile["changes"]["owned"], 1);
62//! // 100 ops have been sequenced, 40 of them before this key existed.
63//! assert_eq!(profile["keys"][0]["ops_since_binding"], 60);
64//! assert_eq!(profile["vouches"]["operator"], "alice");
65//! assert_eq!(profile["vouches"]["received"][0]["voucher"], "bob");
66//! assert_eq!(profile["vouches"]["received"][0]["reciprocal"], false);
67//! assert_eq!(profile["vouches"]["given"], 0);
68//! ```
69
70/// Counts one actor's standing out of a view body.
71///
72/// `view` is a `/api/view` response as JSON, already narrowed to what
73/// the caller may read. `channel` is the name an actor signs as.
74///
75/// An actor with no keys, changes, reviews or checks is not an error:
76/// the answer is a profile with zeroes and `known: false`, because "I
77/// have never heard of them" and "they have done nothing here" are
78/// different sentences and a caller deciding whether to trust a
79/// reviewer needs to be told which one it got.
80#[must_use]
81pub fn of(view: &serde_json::Value, channel: &str) -> serde_json::Value {
82 // `log.next_seq`, not a top-level `next_seq`. The first version of
83 // this read the latter, which `/api/view` does not emit, so every
84 // key on every node reported an age of zero -- and the doctest below
85 // passed the whole time, because it fed a hand-written document
86 // shaped the way the code wished the view were.
87 let next_seq = view["log"]["next_seq"].as_u64().unwrap_or_default();
88
89 // Keys, oldest binding first. `bound_at` is assigned once and never
90 // moves, so this order is one replay reproduces exactly.
91 let mut keys: Vec<serde_json::Value> = Vec::new();
92 if let Some(bindings) = view["bindings"].as_object() {
93 for (hash, binding) in bindings {
94 if binding["channel"].as_str() != Some(channel) {
95 continue;
96 }
97 let bound_at = binding["bound_at"].as_u64().unwrap_or_default();
98 keys.push(serde_json::json!({
99 "key": hash,
100 "operator": binding["operator"],
101 "bound_at": bound_at,
102 // Ops, not elapsed time. A node that sat idle for a month
103 // and a node that took a thousand pushes in an hour are
104 // not comparable on a clock, and this number says so by
105 // being in the only unit the log actually has.
106 "ops_since_binding": next_seq.saturating_sub(bound_at),
107 "revoked": binding["revoked"],
108 }));
109 }
110 }
111 keys.sort_by_key(|key| key["bound_at"].as_u64().unwrap_or_default());
112
113 let mut owned = 0u64;
114 let mut first_verdict: Option<u64> = None;
115 if let Some(changes) = view["changes"].as_object() {
116 for change in changes.values() {
117 if change["owner"].as_str() == Some(channel) {
118 owned += 1;
119 }
120 }
121 }
122
123 let (mut assigned, mut approved, mut requested, mut slashed, mut commented) = (0, 0, 0, 0, 0);
124 if let Some(reviews) = view["reviews"].as_object() {
125 for review in reviews.values() {
126 if review["reviewers"]
127 .as_array()
128 .is_some_and(|who| who.iter().any(|name| name.as_str() == Some(channel)))
129 {
130 assigned += 1;
131 }
132 match review["verdicts"][channel]["verdict"].as_str() {
133 Some("Approve") => approved += 1,
134 Some("RequestChanges") => requested += 1,
135 _ => {}
136 }
137 if review["slashes"][channel].is_string() {
138 slashed += 1;
139 }
140 if let Some(comments) = review["comments"].as_array() {
141 commented += comments
142 .iter()
143 .filter(|c| c["author"].as_str() == Some(channel))
144 .count() as u64;
145 }
146 // The earliest sequence this actor is on record at, across
147 // every verdict they gave. Reviews are keyed by id rather
148 // than by position, so the minimum has to be searched for.
149 if let Some(at) = review["verdicts"][channel]["at"].as_u64() {
150 first_verdict = Some(first_verdict.map_or(at, |seen: u64| seen.min(at)));
151 }
152 }
153 }
154
155 let (mut reported, mut failed) = (0u64, 0u64);
156 if let Some(checks) = view["checks"].as_object() {
157 for check in checks.values() {
158 if check["reporter"].as_str() != Some(channel) {
159 continue;
160 }
161 reported += 1;
162 if check["status"].as_str() == Some("Failed") {
163 failed += 1;
164 }
165 }
166 }
167
168 // D65. Operator-scoped, so an agent channel reads its operator's
169 // graph: `ops/agent` and `ops` are one identity here, and a page
170 // that showed nothing for the agent would be hiding the record
171 // rather than reporting it.
172 let operator = channel.split('/').next().unwrap_or(channel);
173 let mut received: Vec<serde_json::Value> = Vec::new();
174 if let Some(from) = view["vouches"][operator].as_object() {
175 for (voucher, edge) in from {
176 received.push(serde_json::json!({
177 "voucher": voucher,
178 "at": edge["at"].as_u64().unwrap_or_default(),
179 "note": edge["note"],
180 // The cheapest Sybil tell there is, and a fact rather
181 // than a judgement: a ring of identities vouching for
182 // each other is the shape a farm makes, and it looks
183 // identical to a real team until you can see which
184 // edges point both ways.
185 "reciprocal": view["vouches"][voucher.as_str()][operator].is_object(),
186 }));
187 }
188 }
189 received.sort_by_key(|edge| edge["at"].as_u64().unwrap_or_default());
190 let given = view["vouches"].as_object().map_or(0, |subjects| {
191 subjects
192 .values()
193 .filter(|from| from[operator].is_object())
194 .count()
195 });
196
197 // `received` earns a place in this disjunction because a binding
198 // carrying no channel never reaches `keys`, so an operator can be
199 // vouched for here and hold nothing above.
200 let known = !keys.is_empty()
201 || owned > 0
202 || assigned > 0
203 || reported > 0
204 || commented > 0
205 || !received.is_empty();
206
207 serde_json::json!({
208 "channel": channel,
209 "known": known,
210 "keys": keys,
211 "changes": { "owned": owned },
212 "reviews": {
213 "assigned": assigned,
214 "approved": approved,
215 "changes_requested": requested,
216 "slashed": slashed,
217 "comments": commented,
218 "first_verdict_at": first_verdict,
219 },
220 "checks": { "reported": reported, "failed": failed },
221 // Always an object, never null, and `operator` is always
222 // present: an empty `received` says "nobody vouches for this
223 // operator on the records you may read", which is a different
224 // sentence from "this node cannot record vouches" and the two
225 // must not share a rendering.
226 "vouches": {
227 "operator": operator,
228 "received": received,
229 "given": given,
230 },
231 })
232}