Skip to main content

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}