Skip to main content

ljos_cli/
record.rs

1//! Record schema v1: questions and ballots, signed as canonical JSON.
2
3use anyhow::{anyhow, bail, Context, Result};
4use chrono::{DateTime, FixedOffset};
5use serde::de::{self, MapAccess, SeqAccess, Visitor};
6use serde::{Deserialize, Serialize};
7use serde_json::Value;
8use sha2::{Digest, Sha256};
9use std::collections::{BTreeMap, BTreeSet};
10use std::path::{Path, PathBuf};
11
12/// The only accepted schema value for record entries.
13pub const SCHEMA_V1: &str = "record/v1";
14const SIGNING_LABEL: &str = "ljos-record-entry/v1";
15const RESERVED_PLAIN_FIELDS: &[&str] = &["host", "session", "agent"];
16const QUESTION_TYPES: &[&str] = &["correct", "locate", "change", "design", "lead"];
17const BALLOT_STANCES: &[&str] = &["accept", "reject", "withdrawn"];
18
19/// One signed entry in the plain record layer.
20#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
21pub struct SignedEntry {
22    pub schema: String,
23    #[serde(flatten)]
24    pub entry: Entry,
25    pub signature: EntrySignature,
26}
27
28impl SignedEntry {
29    fn unsigned(&self) -> UnsignedEntry {
30        UnsignedEntry {
31            schema: self.schema.clone(),
32            entry: self.entry.clone(),
33        }
34    }
35}
36
37/// A record entry before signing.
38#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
39pub struct UnsignedEntry {
40    pub schema: String,
41    #[serde(flatten)]
42    pub entry: Entry,
43}
44
45/// Signature material for one entry.
46#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
47#[serde(deny_unknown_fields)]
48pub struct EntrySignature {
49    pub key: String,
50    pub sig: String,
51}
52
53/// Entry kinds in schema v1.
54#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
55#[serde(tag = "kind", rename_all = "snake_case")]
56pub enum Entry {
57    Question(QuestionEntry),
58    Ballot(BallotEntry),
59}
60
61/// A question entry.
62#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
63pub struct QuestionEntry {
64    pub id: String,
65    #[serde(rename = "type")]
66    pub question_type: String,
67    pub title: String,
68    pub options: Vec<String>,
69    pub anchors: Vec<String>,
70    pub opened_by: String,
71    pub opened_at: String,
72    #[serde(default, skip_serializing_if = "Option::is_none")]
73    pub dup_of: Option<String>,
74}
75
76/// A ballot entry.
77#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
78pub struct BallotEntry {
79    /// The canonical id: first 16 hex chars of sha256(canonical ballot JSON
80    /// without this `id` field).
81    #[serde(default, skip_serializing_if = "Option::is_none")]
82    pub id: Option<String>,
83    pub question: String,
84    pub stance: String,
85    #[serde(default, skip_serializing_if = "Option::is_none")]
86    pub confidence: Option<f64>,
87    pub voter: String,
88    pub commit: String,
89    #[serde(default)]
90    pub used: Vec<String>,
91    pub lineage: String,
92    #[serde(default, skip_serializing_if = "Option::is_none")]
93    pub replaces: Option<String>,
94    #[serde(default, skip_serializing_if = "Option::is_none")]
95    pub expect: Option<String>,
96    pub cast_at: String,
97}
98
99/// Summary of `ljos record check`.
100#[derive(Debug, Clone, Default, PartialEq, Eq)]
101pub struct CheckReport {
102    pub files: usize,
103    pub entries: usize,
104    pub questions: usize,
105    pub ballots: usize,
106    pub questions_with_live_ballots: usize,
107}
108
109/// Sign one entry with `seat`'s ballot key.
110///
111/// The key is the file [`crate::ballot_sig::seat_key_path`] names, loaded
112/// with [`crate::ballot_sig::load_or_create`], the same path a seat ballot
113/// uses. Ballot entries get `id` filled when absent.
114///
115/// # Errors
116///
117/// The seat key cannot be loaded, or the entry does not pass schema
118/// validation.
119pub fn sign_entry(mut unsigned: UnsignedEntry, seat: &str) -> Result<SignedEntry> {
120    if unsigned.schema.trim().is_empty() {
121        unsigned.schema = SCHEMA_V1.to_string();
122    }
123    if let Entry::Ballot(ballot) = &mut unsigned.entry {
124        if ballot.id.is_none() {
125            ballot.id = Some(ballot_id(ballot)?);
126        }
127    }
128    validate_unsigned(&unsigned)?;
129    let (role, named) = match &unsigned.entry {
130        Entry::Question(question) => ("opener", question.opened_by.as_str()),
131        Entry::Ballot(ballot) => ("voter", ballot.voter.as_str()),
132    };
133    if seat.trim() != named {
134        bail!("record signer `{seat}` must match entry {role} `{named}`");
135    }
136    let path = crate::ballot_sig::seat_key_path(seat)
137        .context("no seat key path: HOME and XDG_DATA_HOME are unset")?;
138    let key = crate::ballot_sig::load_or_create(&path)?;
139    let payload = signing_payload(&unsigned)?;
140    let (public, sig) = crate::ballot_sig::signed_hex(&key, &payload);
141    Ok(SignedEntry {
142        schema: unsigned.schema,
143        entry: unsigned.entry,
144        signature: EntrySignature { key: public, sig },
145    })
146}
147
148/// Verify a signed entry and its schema rules.
149///
150/// # Errors
151///
152/// Schema validation failed, signature data was malformed, or signature
153/// verification failed.
154pub fn verify_entry(entry: &SignedEntry) -> Result<()> {
155    let unsigned = entry.unsigned();
156    validate_unsigned(&unsigned)?;
157    let payload = signing_payload(&unsigned)?;
158    if !crate::ballot_sig::is_hex_len(&entry.signature.key, 64) {
159        bail!("signature key must be 64 hex chars");
160    }
161    if !crate::ballot_sig::is_hex_len(&entry.signature.sig, 128) {
162        bail!("signature must be 128 hex chars");
163    }
164    if crate::ballot_sig::weak_key(&entry.signature.key) {
165        bail!("signature key is a small-order point");
166    }
167    if !crate::ballot_sig::signature_ok(&entry.signature.key, &entry.signature.sig, &payload) {
168        bail!("entry signature does not verify");
169    }
170    Ok(())
171}
172
173/// Run schema + signature checks over many entries, including voter-key pinning.
174///
175/// Every voter id is pinned, including a question's `opened_by`, and so
176/// is the seat after its `/`. A pin file row for the voter id or its seat
177/// is the pin; otherwise the first key seen in this check is. A later
178/// entry under another key is a mismatch, and so is a seat seen under two
179/// keys, whichever comes first. A run id is `<identity>/run-<id>`. Two
180/// ballots of one voter in one hour are not an error here: the tally
181/// drops that voter on that question.
182///
183/// # Errors
184///
185/// Any entry fails validation or signature verification, a pin file voter
186/// is not spelled the way the check looks it up, the pin file pins one
187/// seat to two keys, an entry's voter or seat is pinned to another key,
188/// or a `replaces` names no ballot of the same voter and question.
189pub fn check_entries(entries: &[SignedEntry]) -> Result<CheckReport> {
190    let mut report = CheckReport {
191        entries: entries.len(),
192        ..CheckReport::default()
193    };
194    let (mut pins, pin_source) = crate::ballot_sig::pins_for_check()?;
195    for voter in pins.keys() {
196        validate_pin_voter(voter).with_context(|| format!("pin source: {pin_source}"))?;
197    }
198    let seat_pins = seat_pins(&pins).with_context(|| format!("pin source: {pin_source}"))?;
199    let mut seat_keys = BTreeMap::new();
200    for entry in entries {
201        verify_entry(entry)?;
202        match &entry.entry {
203            Entry::Question(question) => {
204                report.questions += 1;
205                observe_seat_pin(&seat_pins, &question.opened_by, &entry.signature.key)
206                    .with_context(|| format!("pin source: {pin_source}"))?;
207                crate::ballot_sig::observe_pin(
208                    &mut pins,
209                    &question.opened_by,
210                    &entry.signature.key,
211                )
212                .with_context(|| format!("pin source: {pin_source}"))?;
213                observe_seat_key(&mut seat_keys, &question.opened_by, &entry.signature.key)?;
214            }
215            Entry::Ballot(ballot) => {
216                report.ballots += 1;
217                observe_seat_pin(&seat_pins, &ballot.voter, &entry.signature.key)
218                    .with_context(|| format!("pin source: {pin_source}"))?;
219                crate::ballot_sig::observe_pin(&mut pins, &ballot.voter, &entry.signature.key)
220                    .with_context(|| format!("pin source: {pin_source}"))?;
221                observe_seat_key(&mut seat_keys, &ballot.voter, &entry.signature.key)?;
222            }
223        }
224    }
225    report.questions_with_live_ballots = tally(entries)?.len();
226    Ok(report)
227}
228
229/// Refuse a pin row the lookups would never match. Nothing is stripped:
230/// a BOM or a zero-width space before `seat-a` would leave that seat's
231/// pin unread. A row with `/` must be a voter id as written. A bare row
232/// pins the seat [`voter_seat`] folds it to, so `inky@39u` pins `inky`
233/// and `judge:model-1.5` pins `judge-model-1-5`.
234fn validate_pin_voter(voter: &str) -> Result<()> {
235    if !voter.bytes().all(|b| b.is_ascii_graphic()) {
236        bail!("pin voter {voter:?} is not printable ASCII");
237    }
238    if voter.contains('/') {
239        return validate_voter_id(voter).with_context(|| format!("pin voter `{voter}`"));
240    }
241    let seat = crate::seat_slug(voter);
242    if valid_voter_part(voter) && seat != voter {
243        bail!("pin voter `{voter}` is not a canonical seat; the seat is `{seat}`");
244    }
245    Ok(())
246}
247
248/// Pin rows by the seat each pins, as row to key. A bare row pins the
249/// seat it folds to and a voter id row the seat after its `/`. A seat
250/// holds one key: rows such as `inky`, `inky@39u` and `mallory/inky`
251/// must pin the same key, or that seat would vote once under each.
252fn seat_pins(
253    pins: &BTreeMap<String, String>,
254) -> Result<BTreeMap<String, BTreeMap<String, String>>> {
255    let mut seats: BTreeMap<String, BTreeMap<String, String>> = BTreeMap::new();
256    for (row, key) in pins {
257        let seat_part = split_voter(row).map_or(row.as_str(), |(_, seat)| seat);
258        let Some(seat) = voter_seat(seat_part) else {
259            continue;
260        };
261        let rows = seats.entry(seat.clone()).or_default();
262        let fingerprint = crate::ballot_sig::key_fingerprint(key);
263        if let Some((other, pinned)) = rows
264            .iter()
265            .find(|(_, pinned)| crate::ballot_sig::key_fingerprint(pinned) != fingerprint)
266        {
267            bail!(
268                "seat {seat} is pinned to two keys, {}… ({other}) and {}… ({row}); a seat holds one key",
269                &pinned[..12.min(pinned.len())],
270                &key[..12.min(key.len())]
271            );
272        }
273        rows.insert(row.clone(), key.clone());
274    }
275    Ok(seats)
276}
277
278/// Hold `voter` to its seat's pinned key. The voter id is already
279/// canonical, so the part after `/` is the seat as [`seat_pins`] keys it.
280fn observe_seat_pin(
281    seat_pins: &BTreeMap<String, BTreeMap<String, String>>,
282    voter: &str,
283    key: &str,
284) -> Result<()> {
285    let Some((_, seat)) = split_voter(voter) else {
286        return Ok(());
287    };
288    let Some(rows) = seat_pins.get(seat) else {
289        return Ok(());
290    };
291    let fingerprint = crate::ballot_sig::key_fingerprint(key);
292    if rows
293        .values()
294        .any(|pinned| crate::ballot_sig::key_fingerprint(pinned) == fingerprint)
295    {
296        return Ok(());
297    }
298    let pinned: Vec<String> = rows
299        .iter()
300        .map(|(row, pinned)| format!("{}… ({row})", &pinned[..12.min(pinned.len())]))
301        .collect();
302    bail!(
303        "{voter} is pinned through seat {seat} to key {}, got {}…",
304        pinned.join(" or "),
305        &key[..12.min(key.len())]
306    );
307}
308
309/// Hold the seat `voter` sits as to one key across the check. A seat
310/// seen under two keys fails whichever file comes first, and the error
311/// names the keys in one order, so file order cannot pick the seat's key.
312fn observe_seat_key(seats: &mut BTreeMap<String, String>, voter: &str, key: &str) -> Result<()> {
313    let Some((_, seat)) = split_voter(voter) else {
314        return Ok(());
315    };
316    let fingerprint = crate::ballot_sig::key_fingerprint(key);
317    let held = seats
318        .entry(seat.to_string())
319        .or_insert_with(|| fingerprint.clone());
320    if *held == fingerprint {
321        return Ok(());
322    }
323    let (one, two) = if *held < fingerprint {
324        (held.as_str(), fingerprint.as_str())
325    } else {
326        (fingerprint.as_str(), held.as_str())
327    };
328    bail!(
329        "seat {seat} signs under two keys, {}… and {}…; a seat holds one key, so pin it or sign under one",
330        &one[..12.min(one.len())],
331        &two[..12.min(two.len())]
332    );
333}
334
335/// Validate every `.json` entry under `dir`.
336///
337/// # Errors
338///
339/// The directory cannot be read, no JSON entries are found, or any entry is
340/// invalid.
341pub fn check_dir(dir: &Path) -> Result<CheckReport> {
342    let paths = entry_files(dir)?;
343    if paths.is_empty() {
344        bail!("record check: no .json entries under {}", dir.display());
345    }
346    let mut entries = Vec::with_capacity(paths.len());
347    for path in &paths {
348        let raw = std::fs::read_to_string(path)
349            .with_context(|| format!("record check: read {}", path.display()))?;
350        let entry = parse_signed_json(&raw)
351            .with_context(|| format!("record check: parse {}", path.display()))?;
352        entries.push(entry);
353    }
354    let mut report = check_entries(&entries)?;
355    report.files = paths.len();
356    Ok(report)
357}
358
359/// Text form of a successful check.
360#[must_use]
361pub fn format_check_report(report: &CheckReport) -> String {
362    format!(
363        "record check: {} files, {} entries ({} questions, {} ballots), {} questions with live ballots\n",
364        report.files,
365        report.entries,
366        report.questions,
367        report.ballots,
368        report.questions_with_live_ballots
369    )
370}
371
372/// A tracker ballot that does not stand.
373#[derive(Debug, Clone, PartialEq, Eq)]
374pub struct BadBallot {
375    /// The file that holds the ballot, under the tracker root.
376    pub file: PathBuf,
377    /// The ballot's line in that file, from 1.
378    pub line: usize,
379    pub issue: String,
380    pub reason: String,
381}
382
383/// Summary of `ljos record check --tracker`.
384#[derive(Debug, Clone, Default, PartialEq, Eq)]
385pub struct TrackerReport {
386    /// Issues that hold a ballot.
387    pub issues: usize,
388    pub ballots: usize,
389    /// `ISSUE VOTER` for each unsigned ballot held before the first pin.
390    pub legacy: Vec<String>,
391    pub bad: Vec<BadBallot>,
392}
393
394/// Hold every ballot on the tracker at `root` to the push gate's
395/// signature check ([`crate::ballot_sig::seat_check`]): the newest
396/// `ballot-sig:` note of its voter names its choice, verifies, and is
397/// under the key `.ljos-seat-keys` pins for that voter and for no other
398/// seat or voter on the issue, so one key does not count as several
399/// voters. A persona's or a judge's ballot is held to it too, since every
400/// cast signs. An unsigned ballot the tracker held before its first pin,
401/// by commit order, is legacy, as at the push gate
402/// ([`crate::ballot_sig::recorded_before_signing_in`]). Only files are
403/// read, and git for that order.
404///
405/// # Errors
406///
407/// The root holds no project, an issue file cannot be read or parsed, or
408/// the pin file is malformed.
409pub fn check_tracker(root: &Path) -> Result<TrackerReport> {
410    crate::slot::require_tracker_history(root).map_err(anyhow::Error::msg)?;
411    let layout = vissue_core::Layout::resolve(Some(root), None)?;
412    let projects = vissue_core::store::list_projects(&layout)?;
413    if projects.is_empty() {
414        bail!(
415            "record check: no issues.org under {}",
416            layout.projects_dir().display()
417        );
418    }
419    let (pins, _) = crate::ballot_sig::pins_for_check_in(root)?;
420    let mut report = TrackerReport::default();
421    let mut texts: BTreeMap<PathBuf, String> = BTreeMap::new();
422    for project in projects {
423        let board = layout.project_issues_path(&project);
424        let doc = vissue_core::IssueDoc::parse_file(&project, &board)?;
425        for heading in &doc.headings {
426            if !heading.extra_drawers.iter().any(|d| is_votes_drawer(d)) {
427                continue;
428            }
429            let issue = heading.id.as_str();
430            let ballots = vissue_core::ops::ballots(&layout, issue)?;
431            if ballots.is_empty() {
432                continue;
433            }
434            report.issues += 1;
435            let notes: Vec<String> = heading
436                .logbook
437                .iter()
438                .filter_map(crate::logbook_text)
439                .collect();
440            let file = doc.heading_path(issue).to_path_buf();
441            if !texts.contains_key(&file) {
442                let text = std::fs::read_to_string(&file)
443                    .with_context(|| format!("record check: read {}", file.display()))?;
444                texts.insert(file.clone(), text);
445            }
446            let span = (file == board).then_some((heading.line_start, heading.line_end));
447            let legacy = |voter: &str, choice: &str| {
448                crate::ballot_sig::recorded_before_signing_in(root, issue, voter, choice)
449                    .unwrap_or(false)
450            };
451            let cast: Vec<&str> = ballots.iter().map(|b| b.agent.as_str()).collect();
452            for ballot in &ballots {
453                report.ballots += 1;
454                let seat = [(ballot.agent.as_str(), ballot.choice.as_str())];
455                let (problems, old) =
456                    crate::ballot_sig::seat_check(issue, &seat, &notes, &pins, &legacy);
457                if !old.is_empty() {
458                    report.legacy.push(format!("{issue} {}", ballot.agent));
459                    continue;
460                }
461                let reason = problems
462                    .into_iter()
463                    .next()
464                    .or_else(|| shared_key(&ballot.agent, &pins, &cast));
465                let Some(reason) = reason else {
466                    continue;
467                };
468                let line =
469                    ballot_line(&texts[&file], span, &ballot.agent).unwrap_or(heading.line_start);
470                report.bad.push(BadBallot {
471                    file: file.strip_prefix(root).unwrap_or(&file).to_path_buf(),
472                    line,
473                    issue: issue.to_string(),
474                    reason,
475                });
476            }
477        }
478    }
479    Ok(report)
480}
481
482/// The other voters `pins` holds `voter`'s key for, as a reason. Keys are
483/// told apart by [`crate::ballot_sig::key_fingerprint`]. Two rows that
484/// [`voter_seat`] folds to one seat, such as `inky` and `inky@39u`, may
485/// share a key, unless both are among `cast`, the voters on this issue.
486fn shared_key(voter: &str, pins: &BTreeMap<String, String>, cast: &[&str]) -> Option<String> {
487    let key = crate::ballot_sig::key_fingerprint(pins.get(voter)?);
488    let seat = voter_seat(voter);
489    let others: Vec<&str> = pins
490        .iter()
491        .filter(|(other, k)| {
492            other.as_str() != voter
493                && (voter_seat(other) != seat || cast.contains(&other.as_str()))
494                && crate::ballot_sig::key_fingerprint(k) == key
495        })
496        .map(|(other, _)| other.as_str())
497        .collect();
498    (!others.is_empty()).then(|| {
499        format!(
500            "{voter}'s key is also pinned for {}, so one key counts as several voters",
501            others.join(", ")
502        )
503    })
504}
505
506fn is_votes_drawer(drawer: &str) -> bool {
507    drawer
508        .lines()
509        .next()
510        .is_some_and(|l| l.trim().eq_ignore_ascii_case(":VOTES:"))
511}
512
513/// The line of `voter`'s ballot in a `:VOTES:` drawer of `text`, within
514/// `span` when given. The last one, as a read keeps the last of two.
515fn ballot_line(text: &str, span: Option<(usize, usize)>, voter: &str) -> Option<usize> {
516    let (from, to) = span.unwrap_or((1, usize::MAX));
517    let mut inside = false;
518    let mut found = None;
519    for (at, line) in text.lines().enumerate() {
520        let at = at + 1;
521        if at < from || at > to {
522            continue;
523        }
524        let line = line.trim();
525        if line.eq_ignore_ascii_case(":VOTES:") {
526            inside = true;
527        } else if line.eq_ignore_ascii_case(":END:") {
528            inside = false;
529        } else if inside && ballot_voter(line) == Some(voter) {
530            found = Some(at);
531        }
532    }
533    found
534}
535
536/// The voter of a `[STAMP] VOTER: CHOICE` drawer line.
537fn ballot_voter(line: &str) -> Option<&str> {
538    let (_, rest) = line.strip_prefix('[')?.split_once("] ")?;
539    Some(rest.split_once(": ")?.0.trim())
540}
541
542/// Text form of a tracker check: one `FILE:LINE: ISSUE: REASON` line per
543/// ballot that does not stand, then the count.
544#[must_use]
545pub fn format_tracker_report(report: &TrackerReport) -> String {
546    let mut out = String::new();
547    for bad in &report.bad {
548        out.push_str(&format!(
549            "{}:{}: {}: {}\n",
550            bad.file.display(),
551            bad.line,
552            bad.issue,
553            bad.reason
554        ));
555    }
556    if report.bad.is_empty() {
557        out.push_str(&format!(
558            "record check: {} ballots on {} issues stand\n",
559            report.ballots, report.issues
560        ));
561    } else {
562        out.push_str(&format!(
563            "record check: {} of {} ballots on {} issues do not stand\n",
564            report.bad.len(),
565            report.ballots,
566            report.issues
567        ));
568    }
569    if !report.legacy.is_empty() {
570        out.push_str(&format!(
571            "legacy unsigned, committed before signing began: {}\n",
572            report.legacy.join(", ")
573        ));
574    }
575    out
576}
577
578/// Canonical JSON bytes: recursive key sort, no insignificant whitespace.
579///
580/// # Errors
581///
582/// Serialization failed.
583pub fn canonical_json(value: &Value) -> Result<Vec<u8>> {
584    Ok(serde_json::to_vec(&canonical_value(value))?)
585}
586
587/// Ballot id from canonical ballot JSON without `id`.
588///
589/// # Errors
590///
591/// Serialization failed.
592pub fn ballot_id(ballot: &BallotEntry) -> Result<String> {
593    let mut value = serde_json::to_value(ballot)?;
594    let Some(obj) = value.as_object_mut() else {
595        bail!("ballot entry did not serialize as an object");
596    };
597    obj.remove("id");
598    let hash = Sha256::digest(canonical_json(&value)?);
599    let hex = hex::encode(hash);
600    Ok(hex[..16].to_string())
601}
602
603/// Latest unreplaced ballot per voter and question.
604///
605/// Voters are counted per question, by signing key fingerprint. Copies
606/// of one ballot under one key count as that ballot once. A ballot named
607/// by another entry's `replaces` never counts. Of what remains, the
608/// latest `cast_at` per key wins, and a latest `withdrawn` contributes no
609/// vote. A seat that appears under two keys on one question counts under
610/// neither there, whatever order the ballots come in. Two unreplaced ballots in
611/// a voter's latest hour are ambiguous and that voter is dropped on that
612/// question only; a tie in an earlier hour is superseded like any older
613/// ballot.
614///
615/// # Errors
616///
617/// A `replaces` names no ballot, or one from another voter or question, or
618/// a `cast_at` is not an hour timestamp.
619pub fn tally(entries: &[SignedEntry]) -> Result<BTreeMap<String, BTreeMap<String, usize>>> {
620    let mut copies = BTreeSet::new();
621    let mut ballots: Vec<(&BallotEntry, String, Option<String>)> = Vec::new();
622    for entry in entries {
623        let Entry::Ballot(ballot) = &entry.entry else {
624            continue;
625        };
626        let key = crate::ballot_sig::key_fingerprint(&entry.signature.key);
627        // A second file with one ballot under one key is a copy, whatever
628        // the hex case of its key and signature, not a second ballot.
629        if !copies.insert((ballot_id(ballot)?, key.clone())) {
630            continue;
631        }
632        let seat = split_voter(&ballot.voter).and_then(|(_, seat)| voter_seat(seat));
633        ballots.push((ballot, key, seat));
634    }
635    let by_id: BTreeMap<&str, usize> = ballots
636        .iter()
637        .enumerate()
638        .filter_map(|(at, (ballot, _, _))| ballot.id.as_deref().map(|id| (id, at)))
639        .collect();
640    let mut replaced = BTreeSet::new();
641    for (ballot, key, _) in &ballots {
642        let Some(target_id) = ballot.replaces.as_deref() else {
643            continue;
644        };
645        let source_id = ballot.id.as_deref().unwrap_or("<missing-id>");
646        let target_at = by_id.get(target_id).copied().ok_or_else(|| {
647            anyhow!(
648                "ballot {source_id} from {} on {} replaces {target_id}, but no ballot has that id",
649                ballot.voter,
650                ballot.question
651            )
652        })?;
653        let (target, target_key, _) = &ballots[target_at];
654        if target_key != key {
655            bail!(
656                "ballot {source_id} from {} on {} replaces {target_id} from {}; replaces must stay on the same voter",
657                ballot.voter,
658                ballot.question,
659                target.voter
660            );
661        }
662        if target.question != ballot.question {
663            bail!(
664                "ballot {source_id} from {} on {} replaces {target_id} on {}; replaces must stay on the same question",
665                ballot.voter,
666                ballot.question,
667                target.question
668            );
669        }
670        replaced.insert(target_id);
671    }
672    let mut grouped: BTreeMap<String, Vec<(usize, i64)>> = BTreeMap::new();
673    for (at, (ballot, _, _)) in ballots.iter().enumerate() {
674        if ballot.id.as_deref().is_some_and(|id| replaced.contains(id)) {
675            continue;
676        }
677        let stamp = parse_hour_timestamp(&ballot.cast_at)
678            .with_context(|| format!("ballot cast_at `{}`", ballot.cast_at))?
679            .timestamp();
680        grouped
681            .entry(ballot.question.clone())
682            .or_default()
683            .push((at, stamp));
684    }
685    let mut counts: BTreeMap<String, BTreeMap<String, usize>> = BTreeMap::new();
686    for (question, group) in grouped {
687        let mut seat_keys: BTreeMap<&str, BTreeSet<&str>> = BTreeMap::new();
688        for (at, _) in &group {
689            if let Some(seat) = ballots[*at].2.as_deref() {
690                seat_keys
691                    .entry(seat)
692                    .or_default()
693                    .insert(ballots[*at].1.as_str());
694            }
695        }
696        let mut by_key: BTreeMap<String, Vec<(i64, usize)>> = BTreeMap::new();
697        for (at, stamp) in group {
698            let shared = ballots[at]
699                .2
700                .as_deref()
701                .is_some_and(|seat| seat_keys[seat].len() > 1);
702            if shared {
703                continue;
704            }
705            by_key
706                .entry(ballots[at].1.clone())
707                .or_default()
708                .push((stamp, at));
709        }
710        for (_, votes) in by_key {
711            let Some(latest) = votes.iter().map(|(stamp, _)| *stamp).max() else {
712                continue;
713            };
714            let mut current = votes.iter().filter(|(stamp, _)| *stamp == latest);
715            let (Some((_, at)), None) = (current.next(), current.next()) else {
716                continue;
717            };
718            let ballot = ballots[*at].0;
719            if ballot.stance == "withdrawn" {
720                continue;
721            }
722            *counts
723                .entry(question.clone())
724                .or_default()
725                .entry(ballot.stance.clone())
726                .or_default() += 1;
727        }
728    }
729    Ok(counts)
730}
731
732fn validate_unsigned(unsigned: &UnsignedEntry) -> Result<()> {
733    if unsigned.schema != SCHEMA_V1 {
734        bail!(
735            "record schema must be {SCHEMA_V1}, got {}",
736            unsigned.schema.trim()
737        );
738    }
739    match &unsigned.entry {
740        Entry::Question(question) => validate_question(question),
741        Entry::Ballot(ballot) => validate_ballot(ballot),
742    }
743}
744
745fn validate_question(question: &QuestionEntry) -> Result<()> {
746    validate_question_type(&question.question_type)?;
747    validate_question_id(&question.id, Some(&question.question_type))?;
748    if question.title.trim().is_empty() {
749        bail!("question title is required");
750    }
751    if question.options.is_empty() {
752        bail!("question options must not be empty");
753    }
754    if question.options.iter().any(|o| o.trim().is_empty()) {
755        bail!("question options must not contain empty values");
756    }
757    if question.anchors.is_empty() {
758        bail!("question anchors must not be empty");
759    }
760    for anchor in &question.anchors {
761        validate_anchor(anchor)?;
762    }
763    validate_voter_id(&question.opened_by)?;
764    parse_hour_timestamp(&question.opened_at)?;
765    if let Some(dup_of) = &question.dup_of {
766        validate_question_id(dup_of, None)?;
767    }
768    Ok(())
769}
770
771fn validate_ballot(ballot: &BallotEntry) -> Result<()> {
772    let id = ballot.id.as_deref().context("ballot id is required")?;
773    if !is_hex_len(id, 16) {
774        bail!("ballot id must be 16 hex chars");
775    }
776    let computed = ballot_id(ballot)?;
777    if id != computed {
778        bail!("ballot id mismatch: expected {computed}, got {id}");
779    }
780    validate_question_id(&ballot.question, None)?;
781    if !BALLOT_STANCES.contains(&ballot.stance.as_str()) {
782        bail!("ballot stance must be one of `accept`, `reject` or `withdrawn`");
783    }
784    if let Some(confidence) = ballot.confidence {
785        if !(confidence > 0.0 && confidence <= 1.0) {
786            bail!("ballot confidence must be in (0, 1]");
787        }
788    }
789    validate_voter_id(&ballot.voter)?;
790    if !is_hex_len(&ballot.commit, 40) {
791        bail!("ballot commit must be a full 40-hex SHA");
792    }
793    if ballot.used.is_empty() {
794        bail!("ballot used must name one or more evidence refs, or `none`");
795    }
796    if ballot.used.iter().any(|u| u.trim().is_empty()) {
797        bail!("ballot used must not contain empty entries");
798    }
799    if ballot.lineage.trim().is_empty() {
800        bail!("ballot lineage is required");
801    }
802    if let Some(replaces) = &ballot.replaces {
803        if !is_hex_len(replaces, 16) {
804            bail!("ballot replaces must be a 16-hex ballot id");
805        }
806    }
807    if let Some(expect) = &ballot.expect {
808        if expect.trim().is_empty() {
809            bail!("ballot expect must not be empty");
810        }
811    }
812    parse_hour_timestamp(&ballot.cast_at)?;
813    Ok(())
814}
815
816fn validate_question_type(kind: &str) -> Result<()> {
817    if QUESTION_TYPES.contains(&kind) {
818        Ok(())
819    } else {
820        bail!("unsupported question type `{kind}`")
821    }
822}
823
824fn validate_question_id(id: &str, expected_type: Option<&str>) -> Result<()> {
825    let (kind, suffix) = id
826        .split_once('-')
827        .context("question id must be `<type>-<8 hex>`")?;
828    validate_question_type(kind)?;
829    if suffix.len() != 8
830        || !suffix
831            .bytes()
832            .all(|b| matches!(b, b'0'..=b'9' | b'a'..=b'f'))
833    {
834        bail!("question id suffix must be 8 lowercase hex chars");
835    }
836    if let Some(expected_type) = expected_type {
837        if expected_type != kind {
838            bail!("question id type `{kind}` does not match field `{expected_type}`");
839        }
840    }
841    Ok(())
842}
843
844fn validate_anchor(anchor: &str) -> Result<()> {
845    let (kind, rest) = anchor
846        .split_once(':')
847        .context("anchor must be `<kind>:<value>`")?;
848    if rest.trim().is_empty() {
849        bail!("anchor `{anchor}` has an empty value");
850    }
851    match kind {
852        "sha" => {
853            let (repo, sha) = rest
854                .rsplit_once('@')
855                .context("sha anchor must be `sha:<repo>@<40-hex>`")?;
856            if repo.trim().is_empty() || !is_hex_len(sha, 40) {
857                bail!("sha anchor must be `sha:<repo>@<40-hex>`");
858            }
859        }
860        "sym" => {
861            if !rest.contains("::") {
862                bail!("sym anchor must contain `::`");
863            }
864        }
865        "ticket" | "name" => {}
866        "pr" => {
867            let (repo, num) = rest
868                .rsplit_once('#')
869                .context("pr anchor must be `pr:<repo>#<number>`")?;
870            if repo.trim().is_empty() || num.parse::<u64>().is_err() {
871                bail!("pr anchor must be `pr:<repo>#<number>`");
872            }
873        }
874        _ => bail!("unsupported anchor kind `{kind}`"),
875    }
876    Ok(())
877}
878
879fn validate_voter_id(voter: &str) -> Result<()> {
880    let (identity, seat_or_run) =
881        split_voter(voter).ok_or_else(|| anyhow!("voter id must be `<identity>/<seat-or-run>`"))?;
882    if !valid_voter_part(identity) || !valid_voter_part(seat_or_run) {
883        bail!(
884            "voter id must be `<identity>/<seat-or-run>` using ascii letters, digits, '-', '_' or '.'"
885        );
886    }
887    let canonical = canonical_voter(identity, seat_or_run)
888        .ok_or_else(|| anyhow!("voter id `{voter}` needs a letter or digit on each side of `/`"))?;
889    // `run/<id>` names no identity. A run carries one: `<identity>/run-<id>`.
890    if canonical.starts_with("run/") {
891        bail!("voter id `{voter}` has no identity; a run is `<identity>/run-<id>`");
892    }
893    if canonical != voter {
894        bail!("voter id `{voter}` must be written `{canonical}`; sign the entry under that id");
895    }
896    Ok(())
897}
898
899/// A voter id written one way: the identity as its slug and the rest as
900/// [`voter_seat`]. `None` when a part has no letter or digit.
901fn canonical_voter(identity: &str, seat_or_run: &str) -> Option<String> {
902    Some(format!(
903        "{}/{}",
904        slug_of(identity)?,
905        voter_seat(seat_or_run)?
906    ))
907}
908
909/// The seat `name` sits as, read the way the tracker reads a holder: a
910/// `@TAG`, an old pid suffix and an old seat name fold to the seat, so
911/// `Inky`, `inky@39u` and `inky-n9o` are all `inky` and `ljos-bot` is
912/// `ljosbot`. A run keeps its id and a judge its model, version and all:
913/// `run-101` stays, and `judge:model-1.5` is `judge-model-1-5`. `None`
914/// when the name has no letter or digit.
915fn voter_seat(name: &str) -> Option<String> {
916    let seat = slug_of(name.split_once('@').map_or(name, |(seat, _)| seat))?;
917    if seat.starts_with("run-") || seat.starts_with("judge-") {
918        return Some(seat);
919    }
920    Some(crate::seat_slug(&crate::holder_parts(&seat).0))
921}
922
923fn slug_of(part: &str) -> Option<String> {
924    part.bytes()
925        .any(|b| b.is_ascii_alphanumeric())
926        .then(|| crate::seat_slug(part))
927}
928
929fn parse_hour_timestamp(text: &str) -> Result<DateTime<FixedOffset>> {
930    // Local offsets and subseconds are distinct byte strings for one
931    // instant, and each would mint its own ballot id. The plain layer
932    // accepts a single UTC hour form.
933    if !utc_hour_shape(text) {
934        bail!("timestamp `{text}` must be YYYY-MM-DDTHH:00:00Z");
935    }
936    DateTime::parse_from_rfc3339(text)
937        .with_context(|| format!("timestamp `{text}` must be YYYY-MM-DDTHH:00:00Z"))
938}
939
940fn utc_hour_shape(text: &str) -> bool {
941    let b = text.as_bytes();
942    b.len() == 20
943        && b[4] == b'-'
944        && b[7] == b'-'
945        && b[10] == b'T'
946        && b[13] == b':'
947        && b[16] == b':'
948        && b[19] == b'Z'
949        && &b[14..16] == b"00"
950        && &b[17..19] == b"00"
951        && b[..4].iter().all(u8::is_ascii_digit)
952        && b[5..7].iter().all(u8::is_ascii_digit)
953        && b[8..10].iter().all(u8::is_ascii_digit)
954        && b[11..13].iter().all(u8::is_ascii_digit)
955}
956
957fn split_voter(voter: &str) -> Option<(&str, &str)> {
958    let mut parts = voter.split('/');
959    let identity = parts.next()?;
960    let seat_or_run = parts.next()?;
961    (parts.next().is_none() && !identity.is_empty() && !seat_or_run.is_empty())
962        .then_some((identity, seat_or_run))
963}
964
965fn valid_voter_part(part: &str) -> bool {
966    part.bytes()
967        .all(|b| b.is_ascii_alphanumeric() || matches!(b, b'-' | b'_' | b'.'))
968}
969
970fn signing_payload(unsigned: &UnsignedEntry) -> Result<Vec<u8>> {
971    let value = serde_json::to_value(unsigned)?;
972    let canonical = canonical_json(&value)?;
973    let mut payload = Vec::with_capacity(SIGNING_LABEL.len() + 1 + canonical.len());
974    payload.extend_from_slice(SIGNING_LABEL.as_bytes());
975    payload.push(b'\n');
976    payload.extend_from_slice(&canonical);
977    Ok(payload)
978}
979
980struct NoDuplicateKeys(Value);
981
982impl<'de> Deserialize<'de> for NoDuplicateKeys {
983    fn deserialize<D>(deserializer: D) -> std::result::Result<Self, D::Error>
984    where
985        D: serde::Deserializer<'de>,
986    {
987        struct ValueVisitor;
988
989        impl<'de> Visitor<'de> for ValueVisitor {
990            type Value = Value;
991
992            fn expecting(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
993                formatter.write_str("a JSON value")
994            }
995
996            fn visit_bool<E>(self, value: bool) -> std::result::Result<Value, E> {
997                Ok(Value::Bool(value))
998            }
999
1000            fn visit_i64<E>(self, value: i64) -> std::result::Result<Value, E> {
1001                Ok(Value::Number(value.into()))
1002            }
1003
1004            fn visit_u64<E>(self, value: u64) -> std::result::Result<Value, E> {
1005                Ok(Value::Number(value.into()))
1006            }
1007
1008            fn visit_f64<E>(self, value: f64) -> std::result::Result<Value, E>
1009            where
1010                E: de::Error,
1011            {
1012                let number = serde_json::Number::from_f64(value)
1013                    .ok_or_else(|| E::custom("JSON number is not finite"))?;
1014                Ok(Value::Number(number))
1015            }
1016
1017            fn visit_str<E>(self, value: &str) -> std::result::Result<Value, E> {
1018                Ok(Value::String(value.to_string()))
1019            }
1020
1021            fn visit_string<E>(self, value: String) -> std::result::Result<Value, E> {
1022                Ok(Value::String(value))
1023            }
1024
1025            fn visit_none<E>(self) -> std::result::Result<Value, E> {
1026                Ok(Value::Null)
1027            }
1028
1029            fn visit_unit<E>(self) -> std::result::Result<Value, E> {
1030                Ok(Value::Null)
1031            }
1032
1033            fn visit_seq<A>(self, mut seq: A) -> std::result::Result<Value, A::Error>
1034            where
1035                A: SeqAccess<'de>,
1036            {
1037                let mut items = Vec::new();
1038                while let Some(value) = seq.next_element::<NoDuplicateKeys>()? {
1039                    items.push(value.0);
1040                }
1041                Ok(Value::Array(items))
1042            }
1043
1044            fn visit_map<A>(self, mut map: A) -> std::result::Result<Value, A::Error>
1045            where
1046                A: MapAccess<'de>,
1047            {
1048                let mut out = serde_json::Map::new();
1049                while let Some(key) = map.next_key::<String>()? {
1050                    if out.contains_key(&key) {
1051                        return Err(de::Error::custom(format!("duplicate key `{key}`")));
1052                    }
1053                    let value = map.next_value::<NoDuplicateKeys>()?;
1054                    out.insert(key, value.0);
1055                }
1056                Ok(Value::Object(out))
1057            }
1058        }
1059
1060        deserializer.deserialize_any(ValueVisitor).map(Self)
1061    }
1062}
1063
1064fn parse_json_without_duplicate_keys(raw: &str) -> Result<Value> {
1065    let mut de = serde_json::Deserializer::from_str(raw);
1066    let value = NoDuplicateKeys::deserialize(&mut de)
1067        .map(|value| value.0)
1068        .context("record entry is not JSON")?;
1069    de.end().context("record entry is not JSON")?;
1070    Ok(value)
1071}
1072
1073/// Parse one record file. Unknown keys are not dropped before the reserved
1074/// name walk, and the canonical bytes without `signature` must be the typed
1075/// entry. A field added after signing then fails instead of riding along.
1076fn parse_signed_json(raw: &str) -> Result<SignedEntry> {
1077    let value = parse_json_without_duplicate_keys(raw)?;
1078    reject_reserved_plain_fields(&value)?;
1079    let entry: SignedEntry =
1080        serde_json::from_value(value.clone()).context("record entry does not match schema v1")?;
1081    require_signed_coverage(&value, &entry)?;
1082    Ok(entry)
1083}
1084
1085fn require_signed_coverage(raw: &Value, entry: &SignedEntry) -> Result<()> {
1086    let Value::Object(map) = raw else {
1087        bail!("record entry must be a JSON object");
1088    };
1089    let mut unsigned = map.clone();
1090    unsigned.remove("signature");
1091    let raw_canon = canonical_json(&Value::Object(unsigned))?;
1092    let typed = serde_json::to_value(entry.unsigned())?;
1093    let typed_canon = canonical_json(&typed)?;
1094    if raw_canon != typed_canon {
1095        bail!("entry has a field the signature does not cover");
1096    }
1097    Ok(())
1098}
1099
1100fn reject_reserved_plain_fields(value: &Value) -> Result<()> {
1101    reject_reserved_plain_fields_at(value, "$")
1102}
1103
1104fn reject_reserved_plain_fields_at(value: &Value, path: &str) -> Result<()> {
1105    match value {
1106        Value::Object(map) => {
1107            for (k, v) in map {
1108                if RESERVED_PLAIN_FIELDS.contains(&k.as_str()) {
1109                    bail!("plain layer field `{k}` is not allowed at {path}");
1110                }
1111                let next = format!("{path}.{k}");
1112                reject_reserved_plain_fields_at(v, &next)?;
1113            }
1114        }
1115        Value::Array(items) => {
1116            for (i, item) in items.iter().enumerate() {
1117                let next = format!("{path}[{i}]");
1118                reject_reserved_plain_fields_at(item, &next)?;
1119            }
1120        }
1121        _ => {}
1122    }
1123    Ok(())
1124}
1125
1126fn canonical_value(value: &Value) -> Value {
1127    match value {
1128        Value::Object(map) => {
1129            let mut keys: Vec<_> = map.keys().cloned().collect();
1130            keys.sort();
1131            let mut out = serde_json::Map::new();
1132            for key in keys {
1133                out.insert(key.clone(), canonical_value(&map[&key]));
1134            }
1135            Value::Object(out)
1136        }
1137        Value::Array(items) => Value::Array(items.iter().map(canonical_value).collect()),
1138        _ => value.clone(),
1139    }
1140}
1141
1142fn is_hex_len(text: &str, len: usize) -> bool {
1143    crate::ballot_sig::is_hex_len(text, len)
1144}
1145
1146fn entry_files(dir: &Path) -> Result<Vec<PathBuf>> {
1147    let mut pending = vec![dir.to_path_buf()];
1148    let mut files = Vec::new();
1149    while let Some(root) = pending.pop() {
1150        for row in std::fs::read_dir(&root)
1151            .with_context(|| format!("record check: read {}", root.display()))?
1152        {
1153            let row = row?;
1154            let path = row.path();
1155            let ty = row.file_type()?;
1156            if ty.is_dir() {
1157                pending.push(path);
1158            } else if ty.is_file()
1159                && path
1160                    .extension()
1161                    .and_then(|s| s.to_str())
1162                    .is_some_and(|e| e.eq_ignore_ascii_case("json"))
1163            {
1164                files.push(path);
1165            }
1166        }
1167    }
1168    files.sort();
1169    Ok(files)
1170}
1171
1172#[cfg(test)]
1173mod tests {
1174    use super::*;
1175
1176    /// Hold a private seat-key directory for the rest of this test.
1177    fn test_home() -> (crate::EnvLock, tempfile::TempDir) {
1178        let lock = crate::test_env_lock();
1179        let home = tempfile::tempdir().expect("home");
1180        crate::with_env_mutation(|| {
1181            // SAFETY: this test holds the process environment lock.
1182            unsafe {
1183                std::env::set_var("XDG_DATA_HOME", home.path());
1184                std::env::remove_var("LJOS_SEAT_KEY");
1185                std::env::remove_var("LJOS_SEAT");
1186                std::env::remove_var("VISSUE_AGENT");
1187                std::env::remove_var("VISSUE_ROOT");
1188                std::env::remove_var("CURSOR_AGENT_ID");
1189            }
1190        });
1191        (lock, home)
1192    }
1193
1194    fn sign_as(unsigned: UnsignedEntry, seed: u8) -> SignedEntry {
1195        let seat = signer_on_entry(&unsigned).to_string();
1196        let path = crate::ballot_sig::seat_key_path(&seat).expect("seat key path");
1197        if let Some(dir) = path.parent() {
1198            std::fs::create_dir_all(dir).expect("seat dir");
1199        }
1200        std::fs::write(&path, [seed; 32]).expect("seed");
1201        sign_entry(unsigned, &seat).unwrap_or_else(|err| panic!("seat-{seed}: {err:#}"))
1202    }
1203
1204    /// Sign under the `[seed; 32]` key without the schema checks
1205    /// [`sign_entry`] runs first.
1206    fn sign_unchecked(mut unsigned: UnsignedEntry, seed: u8) -> SignedEntry {
1207        if let Entry::Ballot(ballot) = &mut unsigned.entry {
1208            ballot.id = Some(ballot_id(ballot).expect("id"));
1209        }
1210        let key = ed25519_dalek::SigningKey::from_bytes(&[seed; 32]);
1211        let payload = signing_payload(&unsigned).expect("payload");
1212        let (key, sig) = crate::ballot_sig::signed_hex(&key, &payload);
1213        SignedEntry {
1214            schema: unsigned.schema,
1215            entry: unsigned.entry,
1216            signature: EntrySignature { key, sig },
1217        }
1218    }
1219
1220    fn signer_on_entry(unsigned: &UnsignedEntry) -> &str {
1221        match &unsigned.entry {
1222            Entry::Question(question) => &question.opened_by,
1223            Entry::Ballot(ballot) => &ballot.voter,
1224        }
1225    }
1226
1227    fn question_entry() -> UnsignedEntry {
1228        UnsignedEntry {
1229            schema: SCHEMA_V1.to_string(),
1230            entry: Entry::Question(QuestionEntry {
1231                id: "correct-3f9a1c2e".to_string(),
1232                question_type: "correct".to_string(),
1233                title: "is this head mergeable".to_string(),
1234                options: vec!["accept".to_string(), "reject".to_string()],
1235                anchors: vec!["sha:owner/repo@0123456789abcdef0123456789abcdef01234567".to_string()],
1236                opened_by: "alpha/seat-a".to_string(),
1237                opened_at: "2026-10-21T09:00:00Z".to_string(),
1238                dup_of: None,
1239            }),
1240        }
1241    }
1242
1243    fn ballot_entry(voter: &str, stance: &str, cast_at: &str) -> UnsignedEntry {
1244        UnsignedEntry {
1245            schema: SCHEMA_V1.to_string(),
1246            entry: Entry::Ballot(BallotEntry {
1247                id: None,
1248                question: "correct-3f9a1c2e".to_string(),
1249                stance: stance.to_string(),
1250                confidence: Some(0.9),
1251                voter: voter.to_string(),
1252                commit: "0123456789abcdef0123456789abcdef01234567".to_string(),
1253                used: vec!["none".to_string()],
1254                lineage: "model:synthetic".to_string(),
1255                replaces: None,
1256                expect: None,
1257                cast_at: cast_at.to_string(),
1258            }),
1259        }
1260    }
1261
1262    fn ballot_on_question(
1263        voter: &str,
1264        stance: &str,
1265        cast_at: &str,
1266        question: &str,
1267    ) -> UnsignedEntry {
1268        let mut entry = ballot_entry(voter, stance, cast_at);
1269        let Entry::Ballot(ballot) = &mut entry.entry else {
1270            panic!("ballot");
1271        };
1272        ballot.question = question.to_string();
1273        entry
1274    }
1275
1276    fn as_ballot(entry: SignedEntry) -> BallotEntry {
1277        match entry.entry {
1278            Entry::Ballot(ballot) => ballot,
1279            Entry::Question(_) => panic!("expected a ballot entry"),
1280        }
1281    }
1282
1283    #[test]
1284    fn a_signed_entry_verifies() {
1285        let _home = test_home();
1286        let entry = sign_as(question_entry(), 7);
1287        verify_entry(&entry).expect("valid signature");
1288    }
1289
1290    #[test]
1291    fn a_record_entry_is_signed_with_the_seat_ballot_key() {
1292        let _home = test_home();
1293        let entry = sign_as(question_entry(), 7);
1294        let path = crate::ballot_sig::seat_key_path("alpha/seat-a").expect("path");
1295        let key = crate::ballot_sig::load_or_create(&path).expect("seat key");
1296        assert_eq!(
1297            entry.signature.key,
1298            hex::encode(key.verifying_key().to_bytes())
1299        );
1300        verify_entry(&entry).expect("verifies under the seat key");
1301    }
1302
1303    #[test]
1304    fn each_voter_signs_with_its_own_key_file() {
1305        let _home = test_home();
1306        let at = "2026-10-21T10:00:00Z";
1307        let one = sign_entry(ballot_entry("a/b-c", "accept", at), "a/b-c").expect("a/b-c");
1308        let two = sign_entry(ballot_entry("a-b/c", "accept", at), "a-b/c").expect("a-b/c");
1309        assert_ne!(
1310            one.signature.key, two.signature.key,
1311            "a/b-c and a-b/c signed with one key file"
1312        );
1313        let names = ["a/b_c", "a_b/c", "a_b_c", "a:b_c", "a%2fb_c", "", ".", ".."];
1314        let files: BTreeSet<PathBuf> = names
1315            .iter()
1316            .map(|name| crate::ballot_sig::seat_key_file(name).expect("path"))
1317            .collect();
1318        assert_eq!(files.len(), names.len(), "{files:?}");
1319    }
1320
1321    /// Puts the captured variables back when it drops. The caller holds
1322    /// the environment mutation lock across that drop, so a parallel test
1323    /// cannot observe the temporary values, including after a panic.
1324    struct RestoreEnv {
1325        saved: Vec<(&'static str, Option<std::ffi::OsString>)>,
1326    }
1327
1328    impl RestoreEnv {
1329        fn capture(keys: &[&'static str]) -> Self {
1330            Self {
1331                saved: keys
1332                    .iter()
1333                    .map(|key| (*key, std::env::var_os(key)))
1334                    .collect(),
1335            }
1336        }
1337    }
1338
1339    impl Drop for RestoreEnv {
1340        fn drop(&mut self) {
1341            for (key, prev) in &self.saved {
1342                // SAFETY: the caller holds the process environment lock.
1343                unsafe {
1344                    match prev {
1345                        Some(value) => std::env::set_var(key, value),
1346                        None => std::env::remove_var(key),
1347                    }
1348                }
1349            }
1350        }
1351    }
1352
1353    #[test]
1354    fn an_explicit_seat_key_is_used_as_given() {
1355        let _home = test_home();
1356        let root = tempfile::tempdir().expect("root");
1357        let given = root.path().join("seat.key");
1358        let path = crate::with_env_mutation(|| {
1359            let _restore = RestoreEnv::capture(&["LJOS_SEAT_KEY", "CURSOR_AGENT_ID"]);
1360            // SAFETY: this test holds the process environment lock.
1361            unsafe {
1362                std::env::set_var("LJOS_SEAT_KEY", &given);
1363                std::env::set_var("CURSOR_AGENT_ID", "agent-run-1111");
1364            }
1365            crate::ballot_sig::seat_key_path("alpha/seat-a")
1366        });
1367        assert_eq!(
1368            path.as_deref(),
1369            Some(given.as_path()),
1370            "LJOS_SEAT_KEY was not used as given"
1371        );
1372    }
1373
1374    #[test]
1375    fn sign_entry_refuses_a_signer_not_named_on_the_entry() {
1376        let _home = test_home();
1377        let err = sign_entry(
1378            ballot_entry("alpha/seat-a", "accept", "2026-10-21T10:00:00Z"),
1379            "seat-8",
1380        )
1381        .expect_err("mismatched signer");
1382        assert!(
1383            err_chain(&err).contains("voter"),
1384            "mismatched signer passed: {}",
1385            err_chain(&err)
1386        );
1387    }
1388
1389    #[test]
1390    fn sign_entry_refuses_a_voter_id_in_another_spelling() {
1391        let _home = test_home();
1392        let at = "2026-10-21T10:00:00Z";
1393        for (written, canonical) in [
1394            ("Alpha/seat-a", "alpha/seat-a"),
1395            ("alpha./seat-a", "alpha/seat-a"),
1396            ("alpha_/seat-a", "alpha/seat-a"),
1397            ("alpha/Seat-a", "alpha/seat-a"),
1398            ("Alpha/run-101", "alpha/run-101"),
1399            ("alpha./run-101", "alpha/run-101"),
1400            ("alpha_/run-101", "alpha/run-101"),
1401            ("alpha/Run-101", "alpha/run-101"),
1402            ("alpha/run-101.", "alpha/run-101"),
1403            ("alpha/run--101", "alpha/run-101"),
1404            ("alpha/Inky", "alpha/inky"),
1405            ("alpha/inky-39u", "alpha/inky"),
1406            ("alpha/inky-n9o", "alpha/inky"),
1407            ("alpha/disk-saver-t4t", "alpha/disk-saver"),
1408            ("alpha/ljos-bot", "alpha/ljosbot"),
1409            ("alpha/Model-5", "alpha/model-5"),
1410            ("alpha/Ridley-CI-A-555001", "alpha/ridley-ci-a-555001"),
1411            ("alpha/judge-Model-1.5", "alpha/judge-model-1-5"),
1412        ] {
1413            let err = sign_entry(ballot_entry(written, "accept", at), written).expect_err(written);
1414            let text = err_chain(&err);
1415            assert!(
1416                text.contains(&format!("must be written `{canonical}`")),
1417                "{written}: {text}"
1418            );
1419            sign_entry(ballot_entry(canonical, "accept", at), canonical)
1420                .unwrap_or_else(|err| panic!("{canonical}: {err:#}"));
1421        }
1422        for (voter, said) in [
1423            ("Run/run-101", "no identity"),
1424            ("run./run-101", "no identity"),
1425            ("alpha/...", "letter or digit"),
1426            ("_/seat-a", "letter or digit"),
1427        ] {
1428            let err = sign_entry(ballot_entry(voter, "accept", at), voter).expect_err(voter);
1429            assert!(err_chain(&err).contains(said), "{voter}: {err:#}");
1430        }
1431    }
1432
1433    #[test]
1434    fn a_version_suffix_stays_part_of_the_seat() {
1435        let _home = test_home();
1436        let at = "2026-10-21T10:00:00Z";
1437        let entries = ["alpha/model-5", "alpha/model"].map(|voter| {
1438            sign_entry(ballot_entry(voter, "accept", at), voter)
1439                .unwrap_or_else(|err| panic!("{voter}: {err:#}"))
1440        });
1441        check_entries(&entries).expect("two seats");
1442        let read = tally(&entries).expect("tally");
1443        assert_eq!(counted(&read, "accept"), Some(2), "{read:?}");
1444        let err = sign_entry(
1445            ballot_entry("alpha/model-39u", "accept", at),
1446            "alpha/model-39u",
1447        )
1448        .expect_err("a pid tag folds");
1449        let text = err_chain(&err);
1450        assert!(text.contains("must be written `alpha/model`"), "{text}");
1451    }
1452
1453    #[test]
1454    fn a_stored_ballot_in_another_spelling_names_its_voter_id() {
1455        let _home = test_home();
1456        // One seed stands in for the key file `Alpha/seat-a` and
1457        // `alpha/seat-a` share on a case-insensitive volume.
1458        for (written, canonical) in [
1459            ("Alpha/seat-a", "alpha/seat-a"),
1460            ("alpha./seat-a", "alpha/seat-a"),
1461            ("alpha_/run-101", "alpha/run-101"),
1462            ("alpha/Run-101", "alpha/run-101"),
1463            ("alpha/inky-39u", "alpha/inky"),
1464            ("alpha/ljos-bot", "alpha/ljosbot"),
1465        ] {
1466            let kept = sign_as(ballot_entry(canonical, "accept", "2026-10-21T10:00:00Z"), 1);
1467            let stored = sign_unchecked(ballot_entry(written, "accept", "2026-10-21T11:00:00Z"), 1);
1468            let entries = [kept, stored];
1469            let Err(err) = check_entries(&entries) else {
1470                panic!("{written} passed beside {canonical}: {:?}", tally(&entries));
1471            };
1472            let text = err_chain(&err);
1473            assert!(
1474                text.contains(&format!("`{written}` must be written `{canonical}`")),
1475                "{text}"
1476            );
1477        }
1478    }
1479
1480    #[test]
1481    fn a_one_byte_edit_fails_verification() {
1482        let _home = test_home();
1483        let mut entry = sign_as(
1484            ballot_entry("alpha/seat-a", "accept", "2026-10-21T10:00:00Z"),
1485            8,
1486        );
1487        let Entry::Ballot(ballot) = &mut entry.entry else {
1488            panic!("ballot expected");
1489        };
1490        // `lineage` feeds the ballot id. Recompute it so the failure is the
1491        // signature, not the id check that used to hide this edit.
1492        ballot.lineage.push('x');
1493        ballot.id = Some(ballot_id(ballot).expect("id"));
1494        let err = verify_entry(&entry).expect_err("edited ballot");
1495        assert!(
1496            err.to_string().contains("does not verify"),
1497            "edit never reached signature verification: {err}"
1498        );
1499    }
1500
1501    #[test]
1502    fn a_small_order_key_is_refused() {
1503        let _home = test_home();
1504        let key = format!("01{}", "00".repeat(31));
1505        let sig = format!("01{}", "00".repeat(63));
1506        for stance in ["accept", "reject"] {
1507            let mut unsigned = ballot_entry("alpha/seat-a", stance, "2026-10-21T10:00:00Z");
1508            let Entry::Ballot(ballot) = &mut unsigned.entry else {
1509                panic!("ballot");
1510            };
1511            ballot.id = Some(ballot_id(ballot).expect("id"));
1512            let forged = SignedEntry {
1513                schema: unsigned.schema,
1514                entry: unsigned.entry,
1515                signature: EntrySignature {
1516                    key: key.clone(),
1517                    sig: sig.clone(),
1518                },
1519            };
1520            let err = check_value(&serde_json::to_value(&forged).expect("value"))
1521                .expect_err("a small-order key passed record check");
1522            assert!(
1523                err_chain(&err).contains("small-order"),
1524                "{stance}: {}",
1525                err_chain(&err)
1526            );
1527        }
1528        assert!(
1529            !crate::ballot_sig::signature_ok(&key, &sig, b"any message"),
1530            "the identity key verified a signature"
1531        );
1532    }
1533
1534    #[test]
1535    fn number_and_unicode_spellings_follow_the_typed_form() {
1536        let _home = test_home();
1537        let mut unsigned = ballot_entry("alpha/seat-a", "accept", "2026-10-21T10:00:00Z");
1538        let Entry::Ballot(ballot) = &mut unsigned.entry else {
1539            panic!("ballot");
1540        };
1541        ballot.confidence = Some(1.0);
1542        ballot.lineage = "café".to_string();
1543        let signed = sign_as(unsigned, 8);
1544        let id = as_ballot(signed.clone()).id.clone().expect("id");
1545        let text = serde_json::to_string(&signed).expect("json");
1546        let checked = |body: &str| {
1547            let dir = tempfile::tempdir().expect("tempdir");
1548            std::fs::write(dir.path().join("entry.json"), body).expect("write");
1549            check_dir(dir.path())
1550        };
1551        let integer = checked(&text.replace("1.0", "1")).expect_err("integer 1");
1552        assert!(
1553            err_chain(&integer).contains("does not cover"),
1554            "{}",
1555            err_chain(&integer)
1556        );
1557        checked(&text.replace("1.0", "1e0")).expect("1e0");
1558        checked(&text.replace("1.0", "10E-1")).expect("10E-1");
1559        checked(&text.replace("café", "caf\\u00e9")).expect("unicode escape");
1560        let mut decomposed = ballot_entry("alpha/seat-a", "accept", "2026-10-21T10:00:00Z");
1561        let Entry::Ballot(ballot) = &mut decomposed.entry else {
1562            panic!("ballot");
1563        };
1564        ballot.confidence = Some(1.0);
1565        ballot.lineage = "cafe\u{0301}".to_string();
1566        let nfd = as_ballot(sign_as(decomposed, 8)).id.expect("nfd id");
1567        assert_ne!(id, nfd, "unicode normalisation changed the ballot id");
1568    }
1569
1570    #[test]
1571    fn the_documented_ballot_id_matches_the_example() {
1572        let _home = test_home();
1573        let entry = sign_as(
1574            ballot_entry("alpha/seat-a", "accept", "2026-10-21T10:00:00Z"),
1575            8,
1576        );
1577        let id = as_ballot(entry).id.expect("id");
1578        let doc = include_str!("../doc/record-v1.md");
1579        assert!(
1580            doc.contains(&format!("\"id\": \"{id}\"")),
1581            "documented ballot id is stale; computed {id}"
1582        );
1583    }
1584
1585    #[test]
1586    fn docs_name_first_seen_pin_scope() {
1587        let doc = include_str!("../doc/record-v1.md");
1588        let normalized = doc.split_whitespace().collect::<Vec<_>>().join(" ");
1589        assert!(
1590            normalized.contains(
1591                "none for its seat, the first key seen in this check is the pin for this check only"
1592            ),
1593            "record-v1.md must state that first-seen pins are check-local and yield to a seat pin"
1594        );
1595    }
1596
1597    #[test]
1598    fn docs_name_the_deleted_withdrawal_limit() {
1599        let doc = include_str!("../doc/record-v1.md");
1600        let normalized = doc.split_whitespace().collect::<Vec<_>>().join(" ");
1601        assert!(
1602            normalized.contains("A deleted withdrawal revives the earlier ballot"),
1603            "record-v1.md must say the check cannot see a deleted withdrawal"
1604        );
1605    }
1606
1607    #[test]
1608    fn docs_name_the_tracker_and_key_minting_limits() {
1609        let doc = include_str!("../doc/record-v1.md");
1610        let normalized = doc.split_whitespace().collect::<Vec<_>>().join(" ");
1611        assert!(
1612            normalized.contains("A ballot is not bound to a tracker"),
1613            "record-v1.md must say a ballot names no tracker"
1614        );
1615        assert!(
1616            normalized.contains("a new identity and seat signed with a new key is a new voter"),
1617            "record-v1.md must say a newly minted key is a new voter"
1618        );
1619    }
1620
1621    #[test]
1622    fn docs_name_voter_pin_precedence_and_ambiguity_drop() {
1623        let doc = include_str!("../doc/record-v1.md");
1624        let normalized = doc.split_whitespace().collect::<Vec<_>>().join(" ");
1625        assert!(
1626            normalized.contains("a pin file row for a voter id also pins the seat after its `/`"),
1627            "record-v1.md must say a voter-id row pins its seat"
1628        );
1629        assert!(
1630            normalized.contains("are ambiguous and are dropped on that question only"),
1631            "record-v1.md must say same-hour ambiguity is local to one question"
1632        );
1633    }
1634
1635    #[test]
1636    fn a_second_key_under_one_run_id_is_a_mismatch() {
1637        let _home = test_home();
1638        let first = sign_as(
1639            ballot_entry("alpha/run-101", "accept", "2026-10-21T10:00:00Z"),
1640            1,
1641        );
1642        let second = sign_as(
1643            ballot_entry("alpha/run-101", "reject", "2026-10-21T11:00:00Z"),
1644            2,
1645        );
1646        let err = check_entries(&[first, second]).expect_err("must reject run key mismatch");
1647        let text = err_chain(&err);
1648        assert!(text.contains("pinned"), "{text}");
1649        assert!(text.contains("none: first-seen in this check"), "{text}");
1650    }
1651
1652    #[test]
1653    fn a_seat_voter_is_pinned_to_one_key() {
1654        let _home = test_home();
1655        let first = sign_as(
1656            ballot_entry("alpha/seat-a", "accept", "2026-10-21T10:00:00Z"),
1657            1,
1658        );
1659        let second = sign_as(
1660            ballot_entry("alpha/seat-a", "reject", "2026-10-21T11:00:00Z"),
1661            2,
1662        );
1663        let err = check_entries(&[first, second]).expect_err("seat pin");
1664        assert!(err_chain(&err).contains("pinned"), "{}", err_chain(&err));
1665    }
1666
1667    #[test]
1668    fn a_question_opener_is_pinned_with_the_voter() {
1669        let _home = test_home();
1670        let question = sign_as(question_entry(), 1);
1671        let ballot = sign_as(
1672            ballot_entry("alpha/seat-a", "accept", "2026-10-21T10:00:00Z"),
1673            2,
1674        );
1675        let err = check_entries(&[question, ballot]).expect_err("opener pin");
1676        assert!(err_chain(&err).contains("pinned"), "{}", err_chain(&err));
1677    }
1678
1679    #[test]
1680    fn a_run_keeps_its_identity_and_one_key() {
1681        let _home = test_home();
1682        let err = sign_entry(
1683            ballot_entry("run/run-101", "accept", "2026-10-21T10:00:00Z"),
1684            "run/run-101",
1685        )
1686        .expect_err("bare run identity");
1687        assert!(err_chain(&err).contains("identity"), "{}", err_chain(&err));
1688        let first = sign_as(
1689            ballot_entry("alpha/run-101", "accept", "2026-10-21T10:00:00Z"),
1690            1,
1691        );
1692        let second = sign_as(
1693            ballot_entry("alpha/run-101", "reject", "2026-10-21T11:00:00Z"),
1694            2,
1695        );
1696        let err = check_entries(&[first, second]).expect_err("run pin");
1697        assert!(err_chain(&err).contains("pinned"), "{}", err_chain(&err));
1698    }
1699
1700    #[test]
1701    fn latest_ballot_per_voter_wins_and_withdrawn_does_not_count() {
1702        let _home = test_home();
1703        let a1 = sign_as(
1704            ballot_entry("alpha/seat-a", "accept", "2026-10-21T10:00:00Z"),
1705            3,
1706        );
1707        let a2 = sign_as(
1708            ballot_entry("alpha/seat-a", "withdrawn", "2026-10-21T11:00:00Z"),
1709            3,
1710        );
1711        let b1 = sign_as(
1712            ballot_entry("beta/seat-b", "accept", "2026-10-21T10:00:00Z"),
1713            4,
1714        );
1715        let read = tally(&[a1, a2, b1]).expect("tally");
1716        let by_stance = read.get("correct-3f9a1c2e").expect("question tally");
1717        assert_eq!(by_stance.get("accept"), Some(&1));
1718        assert_eq!(by_stance.get("withdrawn"), None);
1719    }
1720
1721    #[test]
1722    fn a_question_id_in_capitals_is_refused() {
1723        let _home = test_home();
1724        let at = "2026-10-21T10:00:00Z";
1725        let lower = sign_as(ballot_entry("alpha/seat-a", "accept", at), 3);
1726        let mut upper = ballot_entry("alpha/seat-a", "accept", at);
1727        let Entry::Ballot(ballot) = &mut upper.entry else {
1728            panic!("ballot");
1729        };
1730        ballot.question = "correct-3F9A1C2E".to_string();
1731        let upper = sign_unchecked(upper, 3);
1732        let entries = [lower, upper];
1733        let Err(err) = check_entries(&entries) else {
1734            panic!("one voter counted twice: {:?}", tally(&entries));
1735        };
1736        assert!(err_chain(&err).contains("lowercase"), "{}", err_chain(&err));
1737    }
1738
1739    #[test]
1740    fn two_unreplaced_ballots_in_one_hour_are_flagged() {
1741        let _home = test_home();
1742        let accept = sign_as(
1743            ballot_entry("alpha/seat-a", "accept", "2026-10-21T10:00:00Z"),
1744            3,
1745        );
1746        let reject = sign_as(
1747            ballot_entry("alpha/seat-a", "reject", "2026-10-21T10:00:00Z"),
1748            3,
1749        );
1750        let read = tally(&[accept, reject]).expect("same-hour ballots are dropped");
1751        assert!(read.is_empty(), "{read:?}");
1752    }
1753
1754    #[test]
1755    fn a_later_ballot_stands_past_a_same_hour_pair() {
1756        let _home = test_home();
1757        let cast = |stance: &str, hour: u8| {
1758            sign_as(
1759                ballot_entry("alpha/seat-a", stance, &format!("2026-10-21T{hour}:00:00Z")),
1760                3,
1761            )
1762        };
1763        let entries = [cast("accept", 10), cast("reject", 10), cast("accept", 12)];
1764        let read = tally(&entries).expect("tally");
1765        assert_eq!(
1766            counted(&read, "accept"),
1767            Some(1),
1768            "a same-hour pair hid a later ballot: {read:?}"
1769        );
1770        assert_eq!(counted(&read, "reject"), None, "{read:?}");
1771        let entries = [cast("accept", 10), cast("accept", 12), cast("reject", 12)];
1772        let read = tally(&entries).expect("tally");
1773        assert!(read.is_empty(), "a latest same-hour pair counted: {read:?}");
1774    }
1775
1776    fn counted(read: &BTreeMap<String, BTreeMap<String, usize>>, stance: &str) -> Option<usize> {
1777        read.get("correct-3f9a1c2e")
1778            .and_then(|stances| stances.get(stance))
1779            .copied()
1780    }
1781
1782    #[test]
1783    fn a_replayed_ballot_does_not_erase_its_voter() {
1784        let _home = test_home();
1785        let cast = sign_as(
1786            ballot_entry("alpha/seat-a", "accept", "2026-10-21T10:00:00Z"),
1787            3,
1788        );
1789        let mut shouted = cast.clone();
1790        shouted.signature.key = shouted.signature.key.to_ascii_uppercase();
1791        shouted.signature.sig = shouted.signature.sig.to_ascii_uppercase();
1792        let later = sign_as(
1793            ballot_entry("alpha/seat-a", "reject", "2026-10-21T12:00:00Z"),
1794            3,
1795        );
1796        for (name, entries, stance) in [
1797            ("copy", vec![cast.clone(), cast.clone()], "accept"),
1798            ("hex case", vec![cast.clone(), shouted.clone()], "accept"),
1799            (
1800                "superseded copy",
1801                vec![cast.clone(), later.clone(), cast.clone()],
1802                "reject",
1803            ),
1804        ] {
1805            let report = check_entries(&entries).unwrap_or_else(|err| panic!("{name}: {err:#}"));
1806            let read = tally(&entries).expect("tally");
1807            assert_eq!(
1808                (report.questions_with_live_ballots, counted(&read, stance)),
1809                (1, Some(1)),
1810                "a replayed ballot erased its voter ({name}): {read:?}"
1811            );
1812        }
1813    }
1814
1815    #[test]
1816    fn one_key_counts_once_on_a_question() {
1817        let _home = test_home();
1818        let entries = [
1819            sign_as(
1820                ballot_entry("alpha/seat-a", "accept", "2026-10-21T10:00:00Z"),
1821                3,
1822            ),
1823            sign_as(
1824                ballot_entry("beta/seat-b", "accept", "2026-10-21T11:00:00Z"),
1825                3,
1826            ),
1827            sign_as(
1828                ballot_entry("alpha/run-101", "reject", "2026-10-21T12:00:00Z"),
1829                3,
1830            ),
1831            sign_as(
1832                ballot_entry("gamma/seat-c", "accept", "2026-10-21T10:00:00Z"),
1833                4,
1834            ),
1835        ];
1836        let report = check_entries(&entries).expect("check");
1837        assert_eq!(report.questions_with_live_ballots, 1);
1838        let read = tally(&entries).expect("tally");
1839        assert_eq!(counted(&read, "reject"), Some(1), "{read:?}");
1840        assert_eq!(counted(&read, "accept"), Some(1), "{read:?}");
1841    }
1842
1843    #[test]
1844    fn distinct_persona_seats_with_distinct_keys_count_apart() {
1845        let _home = test_home();
1846        let entries = [
1847            sign_as(
1848                ballot_entry("ada/persona-a", "accept", "2026-10-21T10:00:00Z"),
1849                1,
1850            ),
1851            sign_as(
1852                ballot_entry("grace/persona-b", "reject", "2026-10-21T11:00:00Z"),
1853                2,
1854            ),
1855        ];
1856        let read = tally(&entries).expect("tally");
1857        assert_eq!(counted(&read, "accept"), Some(1), "{read:?}");
1858        assert_eq!(counted(&read, "reject"), Some(1), "{read:?}");
1859    }
1860
1861    #[test]
1862    fn key_links_on_another_question_do_not_merge_voters_here() {
1863        let _home = test_home();
1864        let entries = [
1865            sign_as(
1866                ballot_on_question(
1867                    "alpha/seat-a",
1868                    "accept",
1869                    "2026-10-21T10:00:00Z",
1870                    "correct-3f9a1c2e",
1871                ),
1872                1,
1873            ),
1874            sign_as(
1875                ballot_on_question(
1876                    "beta/seat-b",
1877                    "reject",
1878                    "2026-10-21T11:00:00Z",
1879                    "correct-3f9a1c2e",
1880                ),
1881                2,
1882            ),
1883            sign_as(
1884                ballot_on_question(
1885                    "gamma/seat-a",
1886                    "accept",
1887                    "2026-10-21T12:00:00Z",
1888                    "change-deadbeef",
1889                ),
1890                2,
1891            ),
1892        ];
1893        let read = tally(&entries).expect("tally");
1894        let first = read.get("correct-3f9a1c2e").expect("first question");
1895        assert_eq!(first.get("accept"), Some(&1), "{read:?}");
1896        assert_eq!(first.get("reject"), Some(&1), "{read:?}");
1897    }
1898
1899    #[test]
1900    fn spellings_of_one_voter_under_one_key_count_once() {
1901        let _home = test_home();
1902        let spellings = [
1903            "alpha/seat-a",
1904            "Alpha/seat-a",
1905            "alpha./seat-a",
1906            "alpha_/seat-a",
1907            "alpha/Seat-a",
1908            "alpha/run-101",
1909            "Alpha/run-101",
1910            "alpha./run-101",
1911            "alpha_/run-101",
1912            "alpha/inky",
1913            "alpha/inky-39u",
1914            "alpha/ljos-bot",
1915            "alpha/ljosbot",
1916            "alpha/judge-model-1-5",
1917        ];
1918        let entries: Vec<SignedEntry> = spellings
1919            .iter()
1920            .zip(1..)
1921            .map(|(voter, hour)| {
1922                let at = format!("2026-10-21T{hour:02}:00:00Z");
1923                sign_unchecked(ballot_entry(voter, "accept", &at), 3)
1924            })
1925            .collect();
1926        let read = tally(&entries).expect("tally");
1927        assert_eq!(counted(&read, "accept"), Some(1), "{read:?}");
1928    }
1929
1930    #[test]
1931    fn one_seat_under_two_keys_counts_under_neither() {
1932        let _home = test_home();
1933        let entries = [
1934            sign_as(
1935                ballot_entry("alpha/inky", "accept", "2026-10-21T10:00:00Z"),
1936                1,
1937            ),
1938            sign_as(
1939                ballot_entry("beta/inky", "reject", "2026-10-21T11:00:00Z"),
1940                2,
1941            ),
1942        ];
1943        let err = check_entries(&entries).expect_err("one seat under two keys");
1944        assert!(
1945            err_chain(&err).contains("seat inky signs under two keys"),
1946            "{}",
1947            err_chain(&err)
1948        );
1949        let read = tally(&entries).expect("tally");
1950        assert!(read.is_empty(), "{read:?}");
1951    }
1952
1953    fn orders<T: Clone>(items: &[T]) -> Vec<Vec<T>> {
1954        if items.len() <= 1 {
1955            return vec![items.to_vec()];
1956        }
1957        let mut out = Vec::new();
1958        for at in 0..items.len() {
1959            let mut rest = items.to_vec();
1960            let first = rest.remove(at);
1961            for mut tail in orders(&rest) {
1962                tail.insert(0, first.clone());
1963                out.push(tail);
1964            }
1965        }
1966        out
1967    }
1968
1969    #[test]
1970    fn a_seat_under_two_keys_reads_the_same_in_every_order() {
1971        let _home = test_home();
1972        let entries = [
1973            sign_as(
1974                ballot_entry("alpha/inky", "accept", "2026-10-21T10:00:00Z"),
1975                1,
1976            ),
1977            sign_as(
1978                ballot_entry("beta/inky", "reject", "2026-10-21T11:00:00Z"),
1979                2,
1980            ),
1981            sign_as(
1982                ballot_entry("gamma/seat-c", "accept", "2026-10-21T10:00:00Z"),
1983                3,
1984            ),
1985            sign_as(
1986                ballot_entry("delta/seat-d", "reject", "2026-10-21T11:00:00Z"),
1987                4,
1988            ),
1989        ];
1990        let read = |order: &[SignedEntry]| {
1991            let check = check_entries(order).map_err(|err| err_chain(&err));
1992            (
1993                check.map(|report| report.questions_with_live_ballots),
1994                tally(order).expect("tally"),
1995            )
1996        };
1997        let first = read(&entries);
1998        for order in orders(&entries) {
1999            assert_eq!(read(&order), first, "file order changed the result");
2000        }
2001        let (check, tallied) = first;
2002        let err = check.expect_err("a seat signed under two keys passed the check");
2003        assert!(err.contains("seat inky"), "{err}");
2004        assert_eq!(counted(&tallied, "accept"), Some(1), "{tallied:?}");
2005        assert_eq!(counted(&tallied, "reject"), Some(1), "{tallied:?}");
2006    }
2007
2008    #[test]
2009    fn a_digit_suffix_is_a_seat_of_its_own() {
2010        let _home = test_home();
2011        let entries = [
2012            sign_as(
2013                ballot_entry("alpha/inky", "accept", "2026-10-21T10:00:00Z"),
2014                1,
2015            ),
2016            sign_as(
2017                ballot_entry("beta/inky-10", "accept", "2026-10-21T11:00:00Z"),
2018                2,
2019            ),
2020        ];
2021        check_entries(&entries).expect("two seats");
2022        let read = tally(&entries).expect("tally");
2023        assert_eq!(counted(&read, "accept"), Some(2), "{read:?}");
2024    }
2025
2026    #[test]
2027    fn runs_and_judges_keep_their_ids_whole() {
2028        let _home = test_home();
2029        for (one, two) in [
2030            ("alpha/judge-model-a-120b", "beta/judge-model-a-20b"),
2031            ("alpha/judge-model-c-70b", "beta/judge-model-c-8b"),
2032            ("alpha/judge-model-d", "beta/judge-model-d-72"),
2033            ("alpha/run-pr-1234", "beta/run-pr-5678"),
2034            ("alpha/judge-model-4", "beta/model-4"),
2035        ] {
2036            let entries = [
2037                sign_as(ballot_entry(one, "accept", "2026-10-21T10:00:00Z"), 1),
2038                sign_as(ballot_entry(two, "accept", "2026-10-21T11:00:00Z"), 2),
2039            ];
2040            check_entries(&entries).unwrap_or_else(|err| panic!("{one} {two}: {err:#}"));
2041            let read = tally(&entries).expect("tally");
2042            assert_eq!(
2043                counted(&read, "accept"),
2044                Some(2),
2045                "{one} and {two} were merged into one voter"
2046            );
2047        }
2048    }
2049
2050    #[test]
2051    fn one_key_under_two_voters_in_one_hour_is_flagged() {
2052        let _home = test_home();
2053        let at = "2026-10-21T10:00:00Z";
2054        let entries = [
2055            sign_as(ballot_entry("alpha/seat-a", "accept", at), 3),
2056            sign_as(ballot_entry("beta/seat-b", "reject", at), 3),
2057        ];
2058        let report = check_entries(&entries).expect("one key, one-hour ambiguity");
2059        assert_eq!(report.questions_with_live_ballots, 0, "{report:?}");
2060        let read = tally(&entries).expect("tally");
2061        assert!(read.is_empty(), "{read:?}");
2062    }
2063
2064    #[test]
2065    fn a_key_in_capital_hex_is_the_same_key() {
2066        let _home = test_home();
2067        let lower = sign_as(
2068            ballot_entry("alpha/seat-a", "accept", "2026-10-21T10:00:00Z"),
2069            3,
2070        );
2071        let mut upper = sign_as(
2072            ballot_entry("beta/seat-b", "accept", "2026-10-21T11:00:00Z"),
2073            3,
2074        );
2075        upper.signature.key = upper.signature.key.to_ascii_uppercase();
2076        assert_ne!(lower.signature.key, upper.signature.key);
2077        let entries = [lower, upper];
2078        check_entries(&entries).expect("capital hex verifies");
2079        let read = tally(&entries).expect("tally");
2080        assert_eq!(counted(&read, "accept"), Some(1), "{read:?}");
2081    }
2082
2083    #[test]
2084    fn a_key_with_a_small_order_part_is_the_same_key() {
2085        let _home = test_home();
2086        let seed = [3u8; 32];
2087        let first = sign_as(
2088            ballot_entry("alpha/seat-a", "accept", "2026-10-21T10:00:00Z"),
2089            3,
2090        );
2091        let mut order_two = [0xff; 32];
2092        order_two[0] = 0xec;
2093        order_two[31] = 0x7f;
2094        let order_two = ed25519_dalek::VerifyingKey::from_bytes(&order_two).expect("point");
2095        assert!(order_two.is_weak());
2096        let public = ed25519_dalek::SigningKey::from_bytes(&seed).verifying_key();
2097        let twin = (public.to_edwards() + order_two.to_edwards())
2098            .compress()
2099            .to_bytes();
2100        let twin = ed25519_dalek::VerifyingKey::from_bytes(&twin).expect("twin");
2101        assert!(!twin.is_weak());
2102        let secret = ed25519_dalek::hazmat::ExpandedSecretKey::from(&seed);
2103        // Under the twin a signature verifies only when its challenge is even.
2104        let second = (11..24)
2105            .find_map(|hour| {
2106                let at = format!("2026-10-21T{hour}:00:00Z");
2107                let mut unsigned = ballot_entry("beta/seat-b", "accept", &at);
2108                let Entry::Ballot(ballot) = &mut unsigned.entry else {
2109                    panic!("ballot");
2110                };
2111                ballot.id = Some(ballot_id(ballot).expect("id"));
2112                let payload = signing_payload(&unsigned).expect("payload");
2113                let sig = ed25519_dalek::hazmat::raw_sign::<sha2::Sha512>(&secret, &payload, &twin);
2114                twin.verify_strict(&payload, &sig)
2115                    .is_ok()
2116                    .then(|| SignedEntry {
2117                        schema: unsigned.schema,
2118                        entry: unsigned.entry,
2119                        signature: EntrySignature {
2120                            key: hex::encode(twin.to_bytes()),
2121                            sig: hex::encode(sig.to_bytes()),
2122                        },
2123                    })
2124            })
2125            .expect("a ballot the twin key verifies");
2126        let entries = [first, second];
2127        check_entries(&entries).expect("both keys verify");
2128        let read = tally(&entries).expect("tally");
2129        assert_eq!(counted(&read, "accept"), Some(1), "{read:?}");
2130    }
2131
2132    fn with_replaces(mut entry: UnsignedEntry, id: &str) -> UnsignedEntry {
2133        let Entry::Ballot(ballot) = &mut entry.entry else {
2134            panic!("ballot");
2135        };
2136        ballot.replaces = Some(id.to_string());
2137        entry
2138    }
2139
2140    #[test]
2141    fn a_same_hour_withdrawal_does_not_count_in_either_order() {
2142        let _home = test_home();
2143        let cast = sign_as(
2144            ballot_entry("alpha/seat-a", "accept", "2026-10-21T10:00:00Z"),
2145            3,
2146        );
2147        let cast_id = as_ballot(cast.clone()).id.clone().expect("id");
2148        let withdrawal = sign_as(
2149            with_replaces(
2150                ballot_entry("alpha/seat-a", "withdrawn", "2026-10-21T10:00:00Z"),
2151                &cast_id,
2152            ),
2153            3,
2154        );
2155        for (name, order) in [
2156            (
2157                "cast then withdrawal",
2158                vec![cast.clone(), withdrawal.clone()],
2159            ),
2160            (
2161                "withdrawal then cast",
2162                vec![withdrawal.clone(), cast.clone()],
2163            ),
2164        ] {
2165            let read = tally(&order).unwrap_or_else(|err| panic!("{name}: {err:#}"));
2166            let accepts = read
2167                .get("correct-3f9a1c2e")
2168                .and_then(|stances| stances.get("accept"))
2169                .copied();
2170            assert_eq!(accepts, None, "{name}: {read:?}");
2171        }
2172        for (cast_name, withdraw_name) in [("a.json", "b.json"), ("b.json", "a.json")] {
2173            let dir = tempfile::tempdir().expect("tempdir");
2174            std::fs::write(
2175                dir.path().join(cast_name),
2176                serde_json::to_vec(&cast).expect("json"),
2177            )
2178            .expect("write cast");
2179            std::fs::write(
2180                dir.path().join(withdraw_name),
2181                serde_json::to_vec(&withdrawal).expect("json"),
2182            )
2183            .expect("write withdrawal");
2184            let report = check_dir(dir.path()).unwrap_or_else(|err| panic!("{cast_name}: {err:#}"));
2185            assert_eq!(
2186                report.questions_with_live_ballots, 0,
2187                "{cast_name} before {withdraw_name}"
2188            );
2189        }
2190    }
2191
2192    #[test]
2193    fn replaces_must_target_the_same_voter_and_question() {
2194        let _home = test_home();
2195        let missing = sign_as(
2196            with_replaces(
2197                ballot_entry("alpha/seat-a", "withdrawn", "2026-10-21T11:00:00Z"),
2198                "deadbeefdeadbeef",
2199            ),
2200            3,
2201        );
2202        let err = tally(std::slice::from_ref(&missing)).expect_err("missing replace target");
2203        assert!(
2204            err_chain(&err).contains("replaces"),
2205            "missing replace target passed: {}",
2206            err_chain(&err)
2207        );
2208
2209        let cast = sign_as(
2210            ballot_entry("alpha/seat-a", "accept", "2026-10-21T10:00:00Z"),
2211            3,
2212        );
2213        let cast_id = as_ballot(cast.clone()).id.expect("id");
2214        let same_key_other_voter = sign_as(
2215            with_replaces(
2216                ballot_entry("beta/seat-b", "withdrawn", "2026-10-21T11:00:00Z"),
2217                &cast_id,
2218            ),
2219            3,
2220        );
2221        let read = tally(&[cast.clone(), same_key_other_voter])
2222            .expect("one key is one voter across voter ids");
2223        assert_eq!(counted(&read, "accept"), None, "{read:?}");
2224
2225        let other_voter = sign_as(
2226            with_replaces(
2227                ballot_entry("beta/seat-b", "withdrawn", "2026-10-21T11:00:00Z"),
2228                &cast_id,
2229            ),
2230            4,
2231        );
2232        let err = tally(&[cast.clone(), other_voter]).expect_err("cross-voter replace");
2233        assert!(
2234            err_chain(&err).contains("same voter"),
2235            "cross-voter replace passed: {}",
2236            err_chain(&err)
2237        );
2238
2239        let mut other_question_unsigned = with_replaces(
2240            ballot_entry("alpha/seat-a", "withdrawn", "2026-10-21T11:00:00Z"),
2241            &cast_id,
2242        );
2243        let Entry::Ballot(ballot) = &mut other_question_unsigned.entry else {
2244            panic!("ballot");
2245        };
2246        ballot.question = "change-deadbeef".to_string();
2247        let other_question = sign_as(other_question_unsigned, 3);
2248        let err = tally(&[cast, other_question]).expect_err("cross-question replace");
2249        assert!(
2250            err_chain(&err).contains("same question"),
2251            "cross-question replace passed: {}",
2252            err_chain(&err)
2253        );
2254    }
2255
2256    #[test]
2257    fn check_dir_reads_and_verifies_json_entries() {
2258        let _home = test_home();
2259        let dir = tempfile::tempdir().expect("tempdir");
2260        let q = sign_as(question_entry(), 5);
2261        let b = sign_as(
2262            ballot_entry("alpha/seat-a", "accept", "2026-10-21T10:00:00Z"),
2263            5,
2264        );
2265        std::fs::write(
2266            dir.path().join("question.json"),
2267            serde_json::to_string_pretty(&q).expect("json"),
2268        )
2269        .expect("write question");
2270        std::fs::write(
2271            dir.path().join("ballot.json"),
2272            serde_json::to_string_pretty(&b).expect("json"),
2273        )
2274        .expect("write ballot");
2275        let report = check_dir(dir.path()).expect("check");
2276        assert_eq!(report.files, 2);
2277        assert_eq!(report.entries, 2);
2278        assert_eq!(report.questions, 1);
2279        assert_eq!(report.ballots, 1);
2280        assert_eq!(report.questions_with_live_ballots, 1);
2281    }
2282
2283    fn err_chain(err: &anyhow::Error) -> String {
2284        format!("{err:#}")
2285    }
2286
2287    #[test]
2288    fn timestamps_must_be_utc_hours() {
2289        let _home = test_home();
2290        for bad in [
2291            "2026-10-21T10:00:00+05:30",
2292            "2026-10-21T04:00:00-05:00",
2293            "2026-10-21T10:00:00.000Z",
2294            "2026-10-21t10:00:00z",
2295            "2026-10-21 10:00:00Z",
2296            "2026-10-21T10:30:00Z",
2297        ] {
2298            let err = sign_entry(ballot_entry("alpha/seat-a", "accept", bad), "alpha/seat-a")
2299                .expect_err(bad);
2300            let text = err_chain(&err);
2301            assert!(
2302                text.contains("YYYY-MM-DDTHH:00:00Z"),
2303                "{bad} was accepted or the error hid the form: {text}"
2304            );
2305        }
2306        let mut opened = question_entry();
2307        let Entry::Question(question) = &mut opened.entry else {
2308            panic!("question");
2309        };
2310        question.opened_at = "2026-10-21T09:00:00+00:00".to_string();
2311        let err = sign_entry(opened, "alpha/seat-a").expect_err("offset opened_at");
2312        assert!(
2313            err_chain(&err).contains("YYYY-MM-DDTHH:00:00Z"),
2314            "{}",
2315            err_chain(&err)
2316        );
2317        sign_as(
2318            ballot_entry("alpha/seat-a", "accept", "2026-10-21T10:00:00Z"),
2319            8,
2320        );
2321    }
2322
2323    #[test]
2324    fn stance_spelling_must_be_exact_lowercase() {
2325        let _home = test_home();
2326        for bad in ["accept ", "accept\u{200b}", "Accept", "WITHDRAWN"] {
2327            let err = sign_entry(
2328                ballot_entry("alpha/seat-a", bad, "2026-10-21T10:00:00Z"),
2329                "alpha/seat-a",
2330            )
2331            .expect_err(bad);
2332            assert!(
2333                err_chain(&err).contains("stance"),
2334                "{bad} passed stance validation: {}",
2335                err_chain(&err)
2336            );
2337        }
2338        for ok in ["accept", "reject", "withdrawn"] {
2339            sign_entry(
2340                ballot_entry("alpha/seat-a", ok, "2026-10-21T10:00:00Z"),
2341                "alpha/seat-a",
2342            )
2343            .unwrap_or_else(|err| panic!("{ok}: {err:#}"));
2344        }
2345    }
2346
2347    /// Reserved names and any key the signature does not cover are refused
2348    /// on the file, including inside `signature`.
2349    #[test]
2350    fn reserved_and_unsigned_fields_are_refused() {
2351        let _home = test_home();
2352        let signed = sign_as(
2353            ballot_entry("alpha/seat-a", "accept", "2026-10-21T10:00:00Z"),
2354            8,
2355        );
2356        let base = serde_json::to_value(&signed).expect("value");
2357
2358        let mut host_in_sig = base.clone();
2359        host_in_sig["signature"]["host"] = serde_json::json!("box");
2360        let err = check_value(&host_in_sig).expect_err("host inside signature");
2361        assert!(
2362            err_chain(&err).contains("host"),
2363            "reserved host was accepted: {}",
2364            err_chain(&err)
2365        );
2366
2367        let mut nested_agent = base.clone();
2368        nested_agent["signature"]["note"] = serde_json::json!({"agent": "runner"});
2369        let err = check_value(&nested_agent).expect_err("nested agent");
2370        assert!(
2371            err_chain(&err).contains("agent"),
2372            "nested agent was accepted: {}",
2373            err_chain(&err)
2374        );
2375
2376        let mut session_in_array = base.clone();
2377        session_in_array["signature"]["meta"] = serde_json::json!([{"session": "s"}]);
2378        let err = check_value(&session_in_array).expect_err("session in array");
2379        assert!(
2380            err_chain(&err).contains("session"),
2381            "session in an array was accepted: {}",
2382            err_chain(&err)
2383        );
2384
2385        let mut rationale = base.clone();
2386        rationale["rationale"] = serde_json::json!("because");
2387        let err = check_value(&rationale).expect_err("unsigned rationale");
2388        assert!(
2389            err_chain(&err).contains("does not cover"),
2390            "unsigned rationale was accepted: {}",
2391            err_chain(&err)
2392        );
2393    }
2394
2395    #[test]
2396    fn unknown_signature_fields_are_refused() {
2397        let _home = test_home();
2398        let signed = sign_as(
2399            ballot_entry("alpha/seat-a", "accept", "2026-10-21T10:00:00Z"),
2400            8,
2401        );
2402        let base = serde_json::to_value(&signed).expect("value");
2403        for (field, value) in [
2404            ("rationale", serde_json::json!("because")),
2405            ("findings", serde_json::json!(["one"])),
2406            ("Host", serde_json::json!("box")),
2407        ] {
2408            let mut changed = base.clone();
2409            changed["signature"][field] = value;
2410            let err = check_value(&changed).expect_err(field);
2411            assert!(
2412                err_chain(&err).contains(field),
2413                "{field} passed under signature: {}",
2414                err_chain(&err)
2415            );
2416        }
2417        let mut nested = base.clone();
2418        nested["signature"]["note"] = serde_json::json!({"Session": "s"});
2419        let err = check_value(&nested).expect_err("nested note.Session");
2420        assert!(
2421            err_chain(&err).contains("note"),
2422            "nested note.Session passed under signature: {}",
2423            err_chain(&err)
2424        );
2425    }
2426
2427    #[test]
2428    fn duplicate_json_keys_are_refused() {
2429        let _home = test_home();
2430        let signed = sign_as(
2431            ballot_entry("alpha/seat-a", "accept", "2026-10-21T10:00:00Z"),
2432            8,
2433        );
2434        let text = serde_json::to_string(&signed).expect("json");
2435
2436        let dup_stance = text.replacen(
2437            "\"stance\":\"accept\",",
2438            "\"stance\":\"accept\",\"stance\":\"accept\",",
2439            1,
2440        );
2441        assert_ne!(dup_stance, text, "duplicate stance fixture");
2442        let err = check_raw(&dup_stance).expect_err("duplicate stance");
2443        assert!(
2444            err_chain(&err).contains("duplicate key `stance`"),
2445            "duplicate stance was not refused as duplicate: {}",
2446            err_chain(&err)
2447        );
2448
2449        let sig = format!("\"sig\":\"{}\"", signed.signature.sig);
2450        let dup_sig = text.replacen(&sig, &format!("{sig},{sig}"), 1);
2451        assert_ne!(dup_sig, text, "duplicate signature fixture");
2452        let err = check_raw(&dup_sig).expect_err("duplicate signature");
2453        assert!(
2454            err_chain(&err).contains("duplicate key `sig`"),
2455            "duplicate signature key was not refused as duplicate: {}",
2456            err_chain(&err)
2457        );
2458    }
2459
2460    #[test]
2461    fn a_pin_file_rejects_a_different_run_key() {
2462        let _home = test_home();
2463        let root = tempfile::tempdir().expect("root");
2464        crate::with_env_mutation(|| {
2465            // SAFETY: this test holds the process environment lock.
2466            unsafe { std::env::set_var("VISSUE_ROOT", root.path()) };
2467        });
2468        let entry = sign_as(
2469            ballot_entry("alpha/run-101", "accept", "2026-10-21T10:00:00Z"),
2470            1,
2471        );
2472        let other = sign_as(
2473            ballot_entry("beta/seat-b", "reject", "2026-10-21T11:00:00Z"),
2474            2,
2475        );
2476        let line = format!("alpha/run-101 {}\n", other.signature.key);
2477        let pin_path = root.path().join(crate::ballot_sig::PIN_FILE);
2478        std::fs::write(&pin_path, line).expect("pin");
2479        let err = check_entries(std::slice::from_ref(&entry)).expect_err("pinned key");
2480        let text = err_chain(&err);
2481        assert!(text.contains("pinned"), "{text}");
2482        assert!(text.contains(pin_path.to_string_lossy().as_ref()), "{text}");
2483    }
2484
2485    #[test]
2486    fn unreadable_pin_file_is_refused() {
2487        let _home = test_home();
2488        let root = tempfile::tempdir().expect("root");
2489        crate::with_env_mutation(|| {
2490            // SAFETY: this test holds the process environment lock.
2491            unsafe { std::env::set_var("VISSUE_ROOT", root.path()) };
2492        });
2493        let pin_path = root.path().join(crate::ballot_sig::PIN_FILE);
2494        std::fs::write(&pin_path, "alpha/run-101 deadbeef\n").expect("pin");
2495        #[cfg(unix)]
2496        {
2497            use std::os::unix::fs::PermissionsExt;
2498            let mut perms = std::fs::metadata(&pin_path).expect("meta").permissions();
2499            perms.set_mode(0o000);
2500            std::fs::set_permissions(&pin_path, perms).expect("chmod");
2501        }
2502        let entry = sign_as(
2503            ballot_entry("alpha/run-101", "accept", "2026-10-21T10:00:00Z"),
2504            1,
2505        );
2506        let err = check_entries(std::slice::from_ref(&entry)).expect_err("unreadable pin file");
2507        assert!(
2508            err_chain(&err).contains(pin_path.to_string_lossy().as_ref()),
2509            "{}",
2510            err_chain(&err)
2511        );
2512    }
2513
2514    #[test]
2515    fn a_dangling_pin_file_link_is_refused() {
2516        let _home = test_home();
2517        let root = tempfile::tempdir().expect("root");
2518        crate::with_env_mutation(|| {
2519            // SAFETY: this test holds the process environment lock.
2520            unsafe { std::env::set_var("VISSUE_ROOT", root.path()) };
2521        });
2522        let pin_path = root.path().join(crate::ballot_sig::PIN_FILE);
2523        std::os::unix::fs::symlink(root.path().join("gone"), &pin_path).expect("symlink");
2524        let entry = sign_as(
2525            ballot_entry("alpha/run-101", "accept", "2026-10-21T10:00:00Z"),
2526            1,
2527        );
2528        let err = check_entries(std::slice::from_ref(&entry))
2529            .expect_err("a dangling pin file link read as no pin file");
2530        assert!(
2531            err_chain(&err).contains(pin_path.to_string_lossy().as_ref()),
2532            "{}",
2533            err_chain(&err)
2534        );
2535    }
2536
2537    #[test]
2538    fn duplicate_pin_voter_lines_are_refused() {
2539        let _home = test_home();
2540        let root = tempfile::tempdir().expect("root");
2541        crate::with_env_mutation(|| {
2542            // SAFETY: this test holds the process environment lock.
2543            unsafe { std::env::set_var("VISSUE_ROOT", root.path()) };
2544        });
2545        let entry = sign_as(
2546            ballot_entry("alpha/run-101", "accept", "2026-10-21T10:00:00Z"),
2547            1,
2548        );
2549        let line = format!(
2550            "alpha/run-101 {}\nalpha/run-101 {}\n",
2551            entry.signature.key, entry.signature.key
2552        );
2553        std::fs::write(root.path().join(crate::ballot_sig::PIN_FILE), line).expect("pin");
2554        let err = check_entries(std::slice::from_ref(&entry)).expect_err("duplicate pin voter");
2555        assert!(err_chain(&err).contains("duplicate"), "{}", err_chain(&err));
2556    }
2557
2558    #[test]
2559    fn a_seat_voter_must_match_the_tracker_seat_pin() {
2560        let _home = test_home();
2561        let root = tempfile::tempdir().expect("root");
2562        crate::with_env_mutation(|| {
2563            // SAFETY: this test holds the process environment lock.
2564            unsafe { std::env::set_var("VISSUE_ROOT", root.path()) };
2565        });
2566        let pinned = sign_as(
2567            ballot_entry("beta/seat-b", "accept", "2026-10-21T10:00:00Z"),
2568            1,
2569        );
2570        let entry = sign_as(
2571            ballot_entry("alpha/seat-a", "accept", "2026-10-21T10:00:00Z"),
2572            2,
2573        );
2574        let line = format!("seat-a {}\n", pinned.signature.key);
2575        std::fs::write(root.path().join(crate::ballot_sig::PIN_FILE), line).expect("pin");
2576        let err = check_entries(std::slice::from_ref(&entry)).expect_err("seat pin mismatch");
2577        assert!(err_chain(&err).contains("seat-a"), "{}", err_chain(&err));
2578    }
2579
2580    #[test]
2581    fn a_voter_pin_row_cannot_give_a_seat_a_second_key() {
2582        let _home = test_home();
2583        let root = tempfile::tempdir().expect("root");
2584        crate::with_env_mutation(|| {
2585            // SAFETY: this test holds the process environment lock.
2586            unsafe { std::env::set_var("VISSUE_ROOT", root.path()) };
2587        });
2588        let at = "2026-10-21T10:00:00Z";
2589        let owner = sign_as(ballot_entry("alpha/inky", "accept", at), 1);
2590        let mallory = sign_as(ballot_entry("mallory/inky", "reject", at), 9);
2591        let (k1, k9) = (&owner.signature.key, &mallory.signature.key);
2592        let pin_path = root.path().join(crate::ballot_sig::PIN_FILE);
2593        for rows in [
2594            format!("inky {k1}\nmallory/inky {k9}\n"),
2595            format!("alpha/inky {k1}\nmallory/inky {k9}\n"),
2596        ] {
2597            std::fs::write(&pin_path, &rows).expect("pin");
2598            let Err(err) = check_entries(std::slice::from_ref(&mallory)) else {
2599                panic!("a second key voted under a pinned seat: {rows}");
2600            };
2601            assert!(
2602                err_chain(&err).contains("seat inky is pinned to two keys"),
2603                "{}",
2604                err_chain(&err)
2605            );
2606        }
2607    }
2608
2609    #[test]
2610    fn a_seat_pin_accepts_same_key_in_capital_hex() {
2611        let _home = test_home();
2612        let root = tempfile::tempdir().expect("root");
2613        crate::with_env_mutation(|| {
2614            // SAFETY: this test holds the process environment lock.
2615            unsafe { std::env::set_var("VISSUE_ROOT", root.path()) };
2616        });
2617        let pinned = sign_as(
2618            ballot_entry("beta/seat-b", "accept", "2026-10-21T10:00:00Z"),
2619            1,
2620        );
2621        let mut entry = sign_as(
2622            ballot_entry("alpha/seat-a", "accept", "2026-10-21T11:00:00Z"),
2623            1,
2624        );
2625        let pin_path = root.path().join(crate::ballot_sig::PIN_FILE);
2626        std::fs::write(&pin_path, format!("seat-a {}\n", pinned.signature.key)).expect("pin");
2627        entry.signature.key = entry.signature.key.to_ascii_uppercase();
2628        check_entries(std::slice::from_ref(&entry))
2629            .unwrap_or_else(|err| panic!("seat pin should match key hex case: {err:#}"));
2630    }
2631
2632    #[test]
2633    fn a_pin_voter_that_is_not_canonical_is_refused() {
2634        let _home = test_home();
2635        let root = tempfile::tempdir().expect("root");
2636        crate::with_env_mutation(|| {
2637            // SAFETY: this test holds the process environment lock.
2638            unsafe { std::env::set_var("VISSUE_ROOT", root.path()) };
2639        });
2640        let pinned = sign_as(
2641            ballot_entry("beta/seat-b", "accept", "2026-10-21T10:00:00Z"),
2642            1,
2643        );
2644        let entry = sign_as(
2645            ballot_entry("alpha/seat-a", "accept", "2026-10-21T10:00:00Z"),
2646            2,
2647        );
2648        let key = &pinned.signature.key;
2649        let pin_path = root.path().join(crate::ballot_sig::PIN_FILE);
2650        for voter in [
2651            "\u{feff}seat-a",
2652            "\u{200b}seat-a",
2653            "Seat-a",
2654            "seat_a",
2655            "Alpha/run-101",
2656        ] {
2657            std::fs::write(&pin_path, format!("{voter} {key}\n")).expect("pin");
2658            let Err(err) = check_entries(std::slice::from_ref(&entry)) else {
2659                panic!("the {voter:?} pin row was skipped");
2660            };
2661            assert!(
2662                err_chain(&err).contains("pin voter"),
2663                "{voter:?}: {}",
2664                err_chain(&err)
2665            );
2666        }
2667    }
2668
2669    #[test]
2670    fn holder_judge_and_legacy_pin_rows_hold_their_seat() {
2671        let _home = test_home();
2672        let root = tempfile::tempdir().expect("root");
2673        crate::with_env_mutation(|| {
2674            // SAFETY: this test holds the process environment lock.
2675            unsafe { std::env::set_var("VISSUE_ROOT", root.path()) };
2676        });
2677        let at = "2026-10-21T10:00:00Z";
2678        let key = sign_as(ballot_entry("beta/seat-b", "accept", at), 1)
2679            .signature
2680            .key;
2681        let rows: String = [
2682            "inky@39u",
2683            "disk-saver-t4t",
2684            "ljos-bot",
2685            "judge:model-1.5",
2686            "seat-b",
2687            "alpha/run-101",
2688        ]
2689        .iter()
2690        .map(|row| format!("{row} {key}\n"))
2691        .collect();
2692        std::fs::write(root.path().join(crate::ballot_sig::PIN_FILE), rows).expect("pin");
2693        for (voter, seat) in [
2694            ("alpha/inky", "inky"),
2695            ("alpha/disk-saver", "disk-saver"),
2696            ("alpha/ljosbot", "ljosbot"),
2697            ("alpha/judge-model-1-5", "judge-model-1-5"),
2698        ] {
2699            let other = sign_as(ballot_entry(voter, "accept", at), 2);
2700            let err = check_entries(std::slice::from_ref(&other))
2701                .expect_err("a second key voted under a pinned seat's alias");
2702            assert!(
2703                err_chain(&err).contains(&format!("{voter} is pinned through seat {seat}")),
2704                "{voter}: {}",
2705                err_chain(&err)
2706            );
2707            let held = sign_as(ballot_entry(voter, "accept", at), 1);
2708            check_entries(std::slice::from_ref(&held))
2709                .unwrap_or_else(|err| panic!("{voter} under its pinned key: {err:#}"));
2710        }
2711        let free = sign_as(ballot_entry("alpha/seat-a", "accept", at), 2);
2712        check_entries(std::slice::from_ref(&free)).expect("no row folds to seat-a");
2713    }
2714
2715    #[test]
2716    fn a_seat_pin_does_not_hold_a_run_or_judge_of_that_name() {
2717        let _home = test_home();
2718        let root = tempfile::tempdir().expect("root");
2719        crate::with_env_mutation(|| {
2720            // SAFETY: this test holds the process environment lock.
2721            unsafe { std::env::set_var("VISSUE_ROOT", root.path()) };
2722        });
2723        let pinned = sign_as(
2724            ballot_entry("beta/seat-b", "accept", "2026-10-21T10:00:00Z"),
2725            1,
2726        );
2727        std::fs::write(
2728            root.path().join(crate::ballot_sig::PIN_FILE),
2729            format!("inky {}\n", pinned.signature.key),
2730        )
2731        .expect("pin");
2732        for voter in ["alpha/run-inky", "alpha/judge-inky"] {
2733            let other = sign_as(ballot_entry(voter, "accept", "2026-10-21T11:00:00Z"), 2);
2734            check_entries(std::slice::from_ref(&other))
2735                .unwrap_or_else(|err| panic!("{voter} was held to seat inky: {err:#}"));
2736        }
2737    }
2738
2739    #[test]
2740    fn a_seat_pinned_to_two_keys_under_two_names_is_refused() {
2741        let _home = test_home();
2742        let root = tempfile::tempdir().expect("root");
2743        crate::with_env_mutation(|| {
2744            // SAFETY: this test holds the process environment lock.
2745            unsafe { std::env::set_var("VISSUE_ROOT", root.path()) };
2746        });
2747        let pin_path = root.path().join(crate::ballot_sig::PIN_FILE);
2748        let at = "2026-10-21T10:00:00Z";
2749        let bare = sign_as(ballot_entry("alpha/inky", "accept", at), 1);
2750        let tagged = sign_as(ballot_entry("beta/inky", "accept", at), 2);
2751        let (key1, key2) = (&bare.signature.key, &tagged.signature.key);
2752        std::fs::write(&pin_path, format!("inky {key1}\ninky@39u {key2}\n")).expect("pin");
2753        let entries = [bare.clone(), tagged.clone()];
2754        let Err(err) = check_entries(&entries) else {
2755            panic!("one seat voted under two keys: {:?}", tally(&entries));
2756        };
2757        let text = err_chain(&err);
2758        assert!(text.contains("seat inky is pinned to two keys"), "{text}");
2759        let read = tally(&entries).expect("one seat under two keys");
2760        assert!(read.is_empty(), "{read:?}");
2761        let later = sign_as(
2762            ballot_entry("beta/inky", "accept", "2026-10-21T11:00:00Z"),
2763            2,
2764        );
2765        let read = tally(&[later, bare.clone()]).expect("tally");
2766        assert!(read.is_empty(), "{read:?}");
2767        std::fs::write(&pin_path, format!("inky {key1}\ninky@39u {key1}\n")).expect("pin");
2768        check_entries(std::slice::from_ref(&bare)).expect("one key under both names");
2769        let err = check_entries(std::slice::from_ref(&tagged)).expect_err("an unpinned key");
2770        let text = err_chain(&err);
2771        assert!(
2772            text.contains("(inky)") && text.contains("(inky@39u)"),
2773            "{text}"
2774        );
2775    }
2776
2777    #[test]
2778    fn a_run_seat_with_a_pin_row_is_held_to_it() {
2779        let _home = test_home();
2780        let root = tempfile::tempdir().expect("root");
2781        crate::with_env_mutation(|| {
2782            // SAFETY: this test holds the process environment lock.
2783            unsafe { std::env::set_var("VISSUE_ROOT", root.path()) };
2784        });
2785        let pinned = sign_as(
2786            ballot_entry("beta/seat-b", "accept", "2026-10-21T10:00:00Z"),
2787            1,
2788        );
2789        let entry = sign_as(
2790            ballot_entry("alpha/run-101", "accept", "2026-10-21T10:00:00Z"),
2791            2,
2792        );
2793        let line = format!("run-101 {}\n", pinned.signature.key);
2794        std::fs::write(root.path().join(crate::ballot_sig::PIN_FILE), line).expect("pin");
2795        let err = check_entries(std::slice::from_ref(&entry))
2796            .expect_err("alpha/run-101 passed beside a run-101 pin row");
2797        assert!(
2798            err_chain(&err).contains("through seat run-101"),
2799            "{}",
2800            err_chain(&err)
2801        );
2802    }
2803
2804    #[test]
2805    fn a_voter_seat_part_must_match_its_slug() {
2806        let _home = test_home();
2807        let root = tempfile::tempdir().expect("root");
2808        crate::with_env_mutation(|| {
2809            // SAFETY: this test holds the process environment lock.
2810            unsafe { std::env::set_var("VISSUE_ROOT", root.path()) };
2811        });
2812        let pinned = sign_as(
2813            ballot_entry("alpha/seat-a", "accept", "2026-10-21T10:00:00Z"),
2814            1,
2815        );
2816        let line = format!("seat-a {}\n", pinned.signature.key);
2817        std::fs::write(root.path().join(crate::ballot_sig::PIN_FILE), line).expect("pin");
2818        let other = sign_unchecked(
2819            ballot_entry("alpha/Seat-a", "accept", "2026-10-21T11:00:00Z"),
2820            1,
2821        );
2822        let entries = [pinned, other];
2823        match check_entries(&entries) {
2824            Ok(_) => panic!(
2825                "alpha/Seat-a passed beside alpha/seat-a: {:?}",
2826                tally(&entries)
2827            ),
2828            Err(err) => {
2829                let text = err_chain(&err);
2830                assert!(text.contains("must be written `alpha/seat-a`"), "{text}");
2831            }
2832        }
2833    }
2834
2835    fn check_value(value: &serde_json::Value) -> anyhow::Result<CheckReport> {
2836        let dir = tempfile::tempdir().expect("tempdir");
2837        std::fs::write(
2838            dir.path().join("entry.json"),
2839            serde_json::to_vec(value).expect("json"),
2840        )
2841        .expect("write");
2842        check_dir(dir.path())
2843    }
2844
2845    fn check_raw(raw: &str) -> anyhow::Result<CheckReport> {
2846        let dir = tempfile::tempdir().expect("tempdir");
2847        std::fs::write(dir.path().join("entry.json"), raw).expect("write");
2848        check_dir(dir.path())
2849    }
2850}