vti-voting 0.9.0

Community voting on the Predicate Credential System: unlinkable voting tickets, casts bound to their ballot, and the ballot box's one-ballot-per-ticket check
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
//! What a manifest must say and what a ballot may answer: the rules JSON Schema cannot state,
//! as checks over the generated wire types.
//!
//! The schema already holds every shape; these hold the relations between fields β€” that a
//! manifest's times are in order, that a choice's bounds fit its options, that a ballot answers
//! each question exactly once and only as its question admits. A ballot box runs
//! [`check_manifest`] at registration and [`check_ballot`] before the pairing check of every
//! cast (VTI-VOT-045); a VTA runs [`check_ballot`] before it builds a cast (VTI-VOT-032).
//!
//! Received JSON is read against the schema before it is parsed into these types; the
//! cross-field rules here assume a document that already passed it.

use std::collections::HashSet;

use sha2::{Digest, Sha256};
use vti_specs::voting::{
    ballot::cast::v0_1::{Answer, Ballot},
    election::register::v0_4::{Manifest, Question, RollRuleRule, SecrecyLevel},
};

/// A broken rule. Each variant is one error local part of the voting families.
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
#[non_exhaustive]
pub enum RuleError {
    /// `invalidManifest`.
    #[error("invalid manifest: {0}")]
    InvalidManifest(String),
    /// `unsupportedSecrecy`: version 0.1 serves open elections only.
    #[error("secrecy level {0} is not served by this version")]
    UnsupportedSecrecy(String),
    /// `manifestMismatch`.
    #[error("the ballot names another election or manifest")]
    ManifestMismatch,
    /// `ballotInvalid`.
    #[error("invalid ballot: {0}")]
    BallotInvalid(String),
    #[error("serialization: {0}")]
    Serialization(String),
}

/// The members a manifest registered under `voting/election/register/0.1` (voting/_shared/0.1)
/// named its instants by, renamed `…At` in voting/_shared/0.2.
const V0_1_INSTANTS: [&str; 3] = ["registrationOpens", "votingOpens", "votingCloses"];

/// Refuse a manifest in the shape of `voting/election/register/0.1`.
///
/// Every party now reads only the 0.2 manifest. One registered under 0.1 cannot be read as
/// 0.2: its digest β€” which every ticket, ballot and certification is bound to β€” is over its
/// 0.1 member names, so renaming them would make it a different election. It is refused by
/// name instead of failing as a manifest missing `registrationOpensAt`.
///
/// # Errors
/// [`RuleError::InvalidManifest`] naming the election and what to do.
pub fn require_current_manifest(manifest: &serde_json::Value) -> Result<(), RuleError> {
    if V0_1_INSTANTS.iter().any(|k| manifest.get(k).is_some()) {
        let id = manifest
            .get("electionId")
            .and_then(serde_json::Value::as_str)
            .unwrap_or("(unnamed)");
        return Err(RuleError::InvalidManifest(format!(
            "election {id} was registered under voting/election/register/0.1 (its manifest \
             names registrationOpens, votingOpens and votingCloses); this build reads only \
             the 0.2 manifest and cannot carry it over. Finish or cancel it on the previous \
             release, or remove it"
        )));
    }
    Ok(())
}

/// Why a board entry written under `voting/board/read/0.1` cannot be served or audited, if it is
/// one: a lifecycle entry (`opened`, `closed`, `closedEarly`, `cancelled`) naming its instant `at`
/// (voting/_shared/0.1) where voting/_shared/0.2 names it `occurredAt`.
///
/// The leaf hash is over the entry's member names, so renaming it would change the board's root
/// and break every receipt and checkpoint already issued against it. It is refused by name
/// instead.
pub(crate) fn v0_1_lifecycle_entry(index: u64, entry: &serde_json::Value) -> Option<String> {
    let lifecycle = matches!(
        entry.get("kind").and_then(serde_json::Value::as_str),
        Some("opened" | "closed" | "closedEarly" | "cancelled")
    );
    (lifecycle && entry.get("at").is_some() && entry.get("occurredAt").is_none()).then(|| {
        format!(
            "board entry {index} names its instant `at` (voting/board/read/0.1); this build \
             serves and reads only voting/board/read/0.2, whose lifecycle entries name it \
             `occurredAt`, and cannot rename it without changing the board. Finish or cancel \
             the election on the previous release, or remove it"
        )
    })
}

/// SHA-256 of the manifest's RFC 8785 (JCS) canonicalization, over the JSON as received.
///
/// # Errors
/// [`RuleError::Serialization`] if the value cannot be canonicalized.
pub fn manifest_digest(manifest: &serde_json::Value) -> Result<[u8; 32], RuleError> {
    let jcs = serde_json_canonicalizer::to_vec(manifest)
        .map_err(|e| RuleError::Serialization(e.to_string()))?;
    Ok(Sha256::digest(jcs).into())
}

/// A SHA-256 digest as the wire carries it: a multihash (`0x12 0x20 β€– digest`), multibase
/// base58btc.
pub fn digest_multibase(digest: &[u8; 32]) -> String {
    let mut mh = Vec::with_capacity(34);
    mh.extend([0x12, 0x20]);
    mh.extend(digest);
    multibase::encode(multibase::Base::Base58Btc, mh)
}

/// The raw digest of a [`digest_multibase`] value, if it is one.
pub fn digest_from_multibase(text: &str) -> Option<[u8; 32]> {
    let (_, bytes) = multibase::decode(text).ok()?;
    match bytes.as_slice() {
        [0x12, 0x20, rest @ ..] => rest.try_into().ok(),
        _ => None,
    }
}

/// The question's key, or `None` for a question type this version does not know: the
/// generated enum grows with the specification, and an unknown type is refused, never guessed.
pub(crate) fn key(q: &Question) -> Option<&str> {
    match q {
        Question::Choice { key, .. }
        | Question::Ranking { key, .. }
        | Question::Score { key, .. }
        | Question::Text { key, .. } => Some(key.as_str()),
        _ => None,
    }
}

fn optional(q: &Question) -> bool {
    match q {
        Question::Choice { optional, .. }
        | Question::Ranking { optional, .. }
        | Question::Score { optional, .. }
        | Question::Text { optional, .. } => *optional,
        _ => false,
    }
}

pub(crate) fn option_keys(q: &Question) -> Vec<&str> {
    match q {
        Question::Choice { options, .. }
        | Question::Ranking { options, .. }
        | Question::Score { options, .. } => options.iter().map(|o| o.key.as_str()).collect(),
        _ => Vec::new(),
    }
}

fn bad_manifest(why: impl Into<String>) -> RuleError {
    RuleError::InvalidManifest(why.into())
}

/// The most trustees an election may have (`TrusteeIndex` runs 1..=64).
pub const MAX_TRUSTEES: usize = 64;

/// The trustee rules of a sealed or tallied-encrypted manifest (VTI-VOT-060 – 064): trustees
/// named, `2 <= threshold <= n <= 64`, neither the community nor the ballot box among them, no
/// running totals, and β€” tallied-encrypted β€” choice questions only.
fn check_trustees(m: &Manifest) -> Result<(), RuleError> {
    let Some(t) = &m.trustees else {
        return Err(bad_manifest(
            "a sealed or tallied-encrypted election names its trustees",
        ));
    };
    let n = t.dids.len();
    if n > MAX_TRUSTEES {
        return Err(bad_manifest(format!(
            "at most {MAX_TRUSTEES} trustees, not {n}"
        )));
    }
    if t.threshold < 2 || t.threshold > n as i64 {
        return Err(bad_manifest(
            "the trustees' threshold must satisfy 2 <= threshold <= trustees",
        ));
    }
    if t.dids
        .iter()
        .map(|d| d.as_str())
        .collect::<HashSet<_>>()
        .len()
        != n
    {
        return Err(bad_manifest("a trustee is named twice"));
    }
    if t.dids
        .iter()
        .any(|d| d.as_str() == m.community.as_str() || d.as_str() == m.ballot_box.as_str())
    {
        return Err(bad_manifest(
            "neither the community nor the ballot box is a trustee",
        ));
    }
    if m.results_before_close {
        return Err(bad_manifest(
            "running totals are an open election's alone (VTI-VOT-061)",
        ));
    }
    if m.secrecy == SecrecyLevel::TalliedEncrypted
        && m.questions
            .iter()
            .any(|q| !matches!(q, Question::Choice { .. }))
    {
        return Err(bad_manifest(
            "a tallied-encrypted election asks choice questions only (VTI-VOT-064)",
        ));
    }
    Ok(())
}

/// The secrecy levels served: all three. The encrypted levels are served end to end β€” the
/// trustees' ceremony and encrypted casts at the ballot box ([`crate::service`]), their
/// certification ([`crate::audit`]) β€” so a ballot box registers them and a VTC creates them.
/// `unsupportedSecrecy` remains for a level a service built from this crate would not serve.
pub const SERVED_LEVELS: &[SecrecyLevel] = &[
    SecrecyLevel::Open,
    SecrecyLevel::Sealed,
    SecrecyLevel::TalliedEncrypted,
];

/// A manifest's cross-field rules (VTI-VOT-010), as a ballot box checks it at registration and
/// a VTC at creation. `ballot_box` is the checking service's own DID, when it is one.
///
/// # Errors
/// [`RuleError::UnsupportedSecrecy`] for a level not in [`SERVED_LEVELS`]; otherwise
/// [`RuleError::InvalidManifest`] naming the first rule broken.
pub fn check_manifest(m: &Manifest, ballot_box: Option<&str>) -> Result<(), RuleError> {
    check_manifest_serving(m, ballot_box, SERVED_LEVELS)
}

/// [`check_manifest`] for a service serving `served`.
///
/// # Errors
/// As [`check_manifest`].
pub fn check_manifest_serving(
    m: &Manifest,
    ballot_box: Option<&str>,
    served: &[SecrecyLevel],
) -> Result<(), RuleError> {
    if !served.contains(&m.secrecy) {
        return Err(RuleError::UnsupportedSecrecy(m.secrecy.to_string()));
    }
    if m.secrecy == SecrecyLevel::Open {
        if m.trustees.is_some() {
            return Err(bad_manifest("an open election has no trustees"));
        }
    } else {
        check_trustees(m)?;
    }
    if !(m.registration_opens_at <= m.voting_opens_at && m.voting_opens_at < m.voting_closes_at) {
        return Err(bad_manifest(
            "the times must satisfy registrationOpensAt <= votingOpensAt < votingClosesAt",
        ));
    }
    if let Some(own) = ballot_box
        && m.ballot_box.as_str() != own
    {
        return Err(bad_manifest("ballotBox names another service"));
    }
    // VTI-VOT-065: at least 1 (the generated type is non-zero, so the schema's `minimum`
    // already holds it); below the SHOULD of 10 the member is told before taking a ticket
    // (VTI-VOT-090), which the disclosure derives from the manifest. A manifest received
    // under a version whose schema says 10 was refused by that schema before it got here.
    let listed = m.roll.listed.as_ref().map_or(0, Vec::len);
    match m.roll.rule {
        RollRuleRule::Listed | RollRuleRule::MembersAndListed if listed == 0 => {
            return Err(bad_manifest(
                "a roll that lists parties must list at least one",
            ));
        }
        RollRuleRule::Members if listed != 0 => {
            return Err(bad_manifest("a members-only roll lists nobody"));
        }
        // VTI-VOT-025, VTI-VOT-026: a credential roll names exactly one DID and lists nobody.
        // The schema states both; they are held here too, as every rule the relation needs.
        RollRuleRule::Credential if m.roll.member_of.is_none() => {
            return Err(bad_manifest("a credential roll names the DID in memberOf"));
        }
        RollRuleRule::Credential if listed != 0 => {
            return Err(bad_manifest("a credential roll lists nobody"));
        }
        _ => {}
    }
    if m.roll.member_of.is_some() && m.roll.rule != RollRuleRule::Credential {
        return Err(bad_manifest("only a credential roll names memberOf"));
    }
    if m.board
        .auditors
        .iter()
        .any(|a| a.as_str() == m.community.as_str() || a.as_str() == m.ballot_box.as_str())
    {
        return Err(bad_manifest(
            "neither the community nor the ballot box audits its own election",
        ));
    }
    let mut seen = HashSet::new();
    for q in &m.questions {
        let Some(k) = key(q) else {
            return Err(bad_manifest(
                "a question of a type this version does not serve",
            ));
        };
        if !seen.insert(k) {
            return Err(bad_manifest(format!("question key {k} repeats")));
        }
        let opts = option_keys(q);
        if opts.iter().collect::<HashSet<_>>().len() != opts.len() {
            return Err(bad_manifest(format!("question {k} repeats an option key")));
        }
        match q {
            Question::Choice {
                min_selections,
                max_selections,
                options,
                ..
            } => {
                let max = max_selections.get();
                if *min_selections > max || max > options.len() as u64 {
                    return Err(bad_manifest(format!(
                        "question {k}: minSelections <= maxSelections <= options"
                    )));
                }
            }
            Question::Score { min, max, .. } if min >= max => {
                return Err(bad_manifest(format!("question {k}: min < max")));
            }
            _ => {}
        }
    }
    Ok(())
}

/// A manifest as received, checked as a community checks one it is asked to create
/// (`vtc/elections/create`): the current 0.2 shape ([`require_current_manifest`]), its typed
/// form, [`check_manifest`], and voting not already closed at `now`.
///
/// Everything a community checks that needs nothing but the manifest and the clock β€” what is
/// left needs the community itself: that `community` is its DID, that `ballotBox` is one of its
/// trusted voting services (VTI-VOT-082), and that `electionId` is unused. The VTC runs it on
/// every create; an operator's tool can run it offline first (`cnm elections manifest check`).
/// The caller validates `manifest` against the schema first.
///
/// # Errors
/// As [`check_manifest`]; [`RuleError::InvalidManifest`] for a 0.1 manifest, one that does not
/// parse, or one whose voting has already closed.
pub fn check_new_manifest(
    manifest: &serde_json::Value,
    now: chrono::DateTime<chrono::Utc>,
) -> Result<Manifest, RuleError> {
    require_current_manifest(manifest)?;
    let m: Manifest =
        serde_json::from_value(manifest.clone()).map_err(|e| bad_manifest(e.to_string()))?;
    check_manifest(&m, None)?;
    if m.voting_closes_at <= now {
        return Err(bad_manifest("votingClosesAt is already past"));
    }
    Ok(m)
}

fn bad_ballot(why: impl Into<String>) -> RuleError {
    RuleError::BallotInvalid(why.into())
}

/// How many answer members an answer carries. Exactly one is admitted; the schema states it as
/// `minProperties`/`maxProperties`, which the generated type cannot.
fn members(a: &Answer) -> usize {
    usize::from(a.choice.is_some())
        + usize::from(a.ranking.is_some())
        + usize::from(!a.scores.is_empty())
        + usize::from(a.text.is_some())
        + usize::from(a.abstain.is_some())
}

/// A ballot against the manifest it was cast under (VTI-VOT-032, VTI-VOT-045).
/// `manifest_digest` is the registered manifest's.
///
/// # Errors
/// [`RuleError::ManifestMismatch`] if the ballot names another election or manifest;
/// [`RuleError::BallotInvalid`] naming the first rule broken.
pub fn check_ballot(m: &Manifest, manifest_digest: &[u8; 32], b: &Ballot) -> Result<(), RuleError> {
    if b.election_id.as_str() != m.election_id.as_str()
        || digest_from_multibase(b.manifest_digest.as_str()).as_ref() != Some(manifest_digest)
    {
        return Err(RuleError::ManifestMismatch);
    }
    let mut answered = HashSet::new();
    for a in &b.answers {
        let qkey = a.question.as_str();
        let Some(q) = m.questions.iter().find(|q| key(q) == Some(qkey)) else {
            return Err(bad_ballot(format!("no question {qkey}")));
        };
        if !answered.insert(qkey) {
            return Err(bad_ballot(format!("question {qkey} answered twice")));
        }
        if members(a) != 1 {
            return Err(bad_ballot(format!(
                "question {qkey}: exactly one answer member"
            )));
        }
        if a.abstain.is_some() {
            if a.abstain != Some(serde_json::Value::Bool(true)) {
                return Err(bad_ballot(format!("question {qkey}: abstain is true")));
            }
            if !optional(q) {
                return Err(bad_ballot(format!(
                    "question {qkey} may not be left unanswered"
                )));
            }
            continue;
        }
        let opts: HashSet<&str> = option_keys(q).into_iter().collect();
        match q {
            Question::Choice {
                min_selections,
                max_selections,
                ..
            } => {
                let Some(choice) = &a.choice else {
                    return Err(bad_ballot(format!("question {qkey} takes a choice")));
                };
                let n = choice.len() as u64;
                if n < *min_selections || n > max_selections.get() {
                    return Err(bad_ballot(format!("question {qkey}: selection count")));
                }
                if choice.iter().collect::<HashSet<_>>().len() != choice.len()
                    || choice.iter().any(|k| !opts.contains(k.as_str()))
                {
                    return Err(bad_ballot(format!(
                        "question {qkey}: unknown or repeated option"
                    )));
                }
            }
            Question::Ranking { partial, .. } => {
                let Some(ranking) = &a.ranking else {
                    return Err(bad_ballot(format!("question {qkey} takes a ranking")));
                };
                let distinct = ranking.iter().collect::<HashSet<_>>().len() == ranking.len();
                let known = ranking.iter().all(|k| opts.contains(k.as_str()));
                let complete = if *partial {
                    !ranking.is_empty()
                } else {
                    ranking.len() == opts.len()
                };
                if !(distinct && known && complete) {
                    return Err(bad_ballot(format!("question {qkey}: ranking")));
                }
            }
            Question::Score { min, max, .. } => {
                let scored: HashSet<&str> = a.scores.keys().map(|k| k.as_str()).collect();
                if scored != opts {
                    return Err(bad_ballot(format!("question {qkey}: score every option")));
                }
                if a.scores.values().any(|v| v < min || v > max) {
                    return Err(bad_ballot(format!("question {qkey}: score out of bounds")));
                }
            }
            Question::Text { max_length, .. } => {
                let Some(text) = &a.text else {
                    return Err(bad_ballot(format!("question {qkey} takes text")));
                };
                if text.as_str().chars().count() as u64 > max_length.get() {
                    return Err(bad_ballot(format!("question {qkey}: text too long")));
                }
            }
            _ => return Err(bad_ballot(format!("question {qkey}: unsupported type"))),
        }
    }
    if let Some(q) = m
        .questions
        .iter()
        .find(|q| key(q).is_none_or(|k| !answered.contains(k)))
    {
        return Err(bad_ballot(format!(
            "question {} unanswered (an optional question is answered with abstain)",
            key(q).unwrap_or("?")
        )));
    }
    Ok(())
}

#[cfg(test)]
mod tests {
    use serde_json::json;

    use super::v0_1_lifecycle_entry;

    /// A lifecycle entry written under `voting/board/read/0.1` (`at`) is refused by name; one
    /// written under 0.2 (`occurredAt`), and every other kind, is read.
    #[test]
    fn a_0_1_lifecycle_entry_is_refused_by_name() {
        let old = json!({ "kind": "opened", "at": "2026-11-09T09:00:00Z" });
        assert!(v0_1_lifecycle_entry(3, &old).is_some_and(|why| why.contains("board entry 3")));
        let new = json!({ "kind": "closed", "occurredAt": "2026-11-09T09:00:00Z" });
        assert!(v0_1_lifecycle_entry(0, &new).is_none());
        let cast = json!({ "kind": "cast", "cast": {} });
        assert!(v0_1_lifecycle_entry(0, &cast).is_none());
    }
}