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(|e| e.note.clone())
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/GPT-5", "alpha/gpt-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/gpt-5", "alpha/gpt"].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(ballot_entry("alpha/gpt-39u", "accept", at), "alpha/gpt-39u")
1445            .expect_err("a pid tag folds");
1446        let text = err_chain(&err);
1447        assert!(text.contains("must be written `alpha/gpt`"), "{text}");
1448    }
1449
1450    #[test]
1451    fn a_stored_ballot_in_another_spelling_names_its_voter_id() {
1452        let _home = test_home();
1453        // One seed stands in for the key file `Alpha/seat-a` and
1454        // `alpha/seat-a` share on a case-insensitive volume.
1455        for (written, canonical) in [
1456            ("Alpha/seat-a", "alpha/seat-a"),
1457            ("alpha./seat-a", "alpha/seat-a"),
1458            ("alpha_/run-101", "alpha/run-101"),
1459            ("alpha/Run-101", "alpha/run-101"),
1460            ("alpha/inky-39u", "alpha/inky"),
1461            ("alpha/ljos-bot", "alpha/ljosbot"),
1462        ] {
1463            let kept = sign_as(ballot_entry(canonical, "accept", "2026-10-21T10:00:00Z"), 1);
1464            let stored = sign_unchecked(ballot_entry(written, "accept", "2026-10-21T11:00:00Z"), 1);
1465            let entries = [kept, stored];
1466            let Err(err) = check_entries(&entries) else {
1467                panic!("{written} passed beside {canonical}: {:?}", tally(&entries));
1468            };
1469            let text = err_chain(&err);
1470            assert!(
1471                text.contains(&format!("`{written}` must be written `{canonical}`")),
1472                "{text}"
1473            );
1474        }
1475    }
1476
1477    #[test]
1478    fn a_one_byte_edit_fails_verification() {
1479        let _home = test_home();
1480        let mut entry = sign_as(
1481            ballot_entry("alpha/seat-a", "accept", "2026-10-21T10:00:00Z"),
1482            8,
1483        );
1484        let Entry::Ballot(ballot) = &mut entry.entry else {
1485            panic!("ballot expected");
1486        };
1487        // `lineage` feeds the ballot id. Recompute it so the failure is the
1488        // signature, not the id check that used to hide this edit.
1489        ballot.lineage.push('x');
1490        ballot.id = Some(ballot_id(ballot).expect("id"));
1491        let err = verify_entry(&entry).expect_err("edited ballot");
1492        assert!(
1493            err.to_string().contains("does not verify"),
1494            "edit never reached signature verification: {err}"
1495        );
1496    }
1497
1498    #[test]
1499    fn a_small_order_key_is_refused() {
1500        let _home = test_home();
1501        let key = format!("01{}", "00".repeat(31));
1502        let sig = format!("01{}", "00".repeat(63));
1503        for stance in ["accept", "reject"] {
1504            let mut unsigned = ballot_entry("alpha/seat-a", stance, "2026-10-21T10:00:00Z");
1505            let Entry::Ballot(ballot) = &mut unsigned.entry else {
1506                panic!("ballot");
1507            };
1508            ballot.id = Some(ballot_id(ballot).expect("id"));
1509            let forged = SignedEntry {
1510                schema: unsigned.schema,
1511                entry: unsigned.entry,
1512                signature: EntrySignature {
1513                    key: key.clone(),
1514                    sig: sig.clone(),
1515                },
1516            };
1517            let err = check_value(&serde_json::to_value(&forged).expect("value"))
1518                .expect_err("a small-order key passed record check");
1519            assert!(
1520                err_chain(&err).contains("small-order"),
1521                "{stance}: {}",
1522                err_chain(&err)
1523            );
1524        }
1525        assert!(
1526            !crate::ballot_sig::signature_ok(&key, &sig, b"any message"),
1527            "the identity key verified a signature"
1528        );
1529    }
1530
1531    #[test]
1532    fn number_and_unicode_spellings_follow_the_typed_form() {
1533        let _home = test_home();
1534        let mut unsigned = ballot_entry("alpha/seat-a", "accept", "2026-10-21T10:00:00Z");
1535        let Entry::Ballot(ballot) = &mut unsigned.entry else {
1536            panic!("ballot");
1537        };
1538        ballot.confidence = Some(1.0);
1539        ballot.lineage = "café".to_string();
1540        let signed = sign_as(unsigned, 8);
1541        let id = as_ballot(signed.clone()).id.clone().expect("id");
1542        let text = serde_json::to_string(&signed).expect("json");
1543        let checked = |body: &str| {
1544            let dir = tempfile::tempdir().expect("tempdir");
1545            std::fs::write(dir.path().join("entry.json"), body).expect("write");
1546            check_dir(dir.path())
1547        };
1548        let integer = checked(&text.replace("1.0", "1")).expect_err("integer 1");
1549        assert!(
1550            err_chain(&integer).contains("does not cover"),
1551            "{}",
1552            err_chain(&integer)
1553        );
1554        checked(&text.replace("1.0", "1e0")).expect("1e0");
1555        checked(&text.replace("1.0", "10E-1")).expect("10E-1");
1556        checked(&text.replace("café", "caf\\u00e9")).expect("unicode escape");
1557        let mut decomposed = ballot_entry("alpha/seat-a", "accept", "2026-10-21T10:00:00Z");
1558        let Entry::Ballot(ballot) = &mut decomposed.entry else {
1559            panic!("ballot");
1560        };
1561        ballot.confidence = Some(1.0);
1562        ballot.lineage = "cafe\u{0301}".to_string();
1563        let nfd = as_ballot(sign_as(decomposed, 8)).id.expect("nfd id");
1564        assert_ne!(id, nfd, "unicode normalisation changed the ballot id");
1565    }
1566
1567    #[test]
1568    fn the_documented_ballot_id_matches_the_example() {
1569        let _home = test_home();
1570        let entry = sign_as(
1571            ballot_entry("alpha/seat-a", "accept", "2026-10-21T10:00:00Z"),
1572            8,
1573        );
1574        let id = as_ballot(entry).id.expect("id");
1575        let doc = include_str!("../doc/record-v1.md");
1576        assert!(
1577            doc.contains(&format!("\"id\": \"{id}\"")),
1578            "documented ballot id is stale; computed {id}"
1579        );
1580    }
1581
1582    #[test]
1583    fn docs_name_first_seen_pin_scope() {
1584        let doc = include_str!("../doc/record-v1.md");
1585        let normalized = doc.split_whitespace().collect::<Vec<_>>().join(" ");
1586        assert!(
1587            normalized.contains(
1588                "none for its seat, the first key seen in this check is the pin for this check only"
1589            ),
1590            "record-v1.md must state that first-seen pins are check-local and yield to a seat pin"
1591        );
1592    }
1593
1594    #[test]
1595    fn docs_name_the_deleted_withdrawal_limit() {
1596        let doc = include_str!("../doc/record-v1.md");
1597        let normalized = doc.split_whitespace().collect::<Vec<_>>().join(" ");
1598        assert!(
1599            normalized.contains("A deleted withdrawal revives the earlier ballot"),
1600            "record-v1.md must say the check cannot see a deleted withdrawal"
1601        );
1602    }
1603
1604    #[test]
1605    fn docs_name_the_tracker_and_key_minting_limits() {
1606        let doc = include_str!("../doc/record-v1.md");
1607        let normalized = doc.split_whitespace().collect::<Vec<_>>().join(" ");
1608        assert!(
1609            normalized.contains("A ballot is not bound to a tracker"),
1610            "record-v1.md must say a ballot names no tracker"
1611        );
1612        assert!(
1613            normalized.contains("a new identity and seat signed with a new key is a new voter"),
1614            "record-v1.md must say a newly minted key is a new voter"
1615        );
1616    }
1617
1618    #[test]
1619    fn docs_name_voter_pin_precedence_and_ambiguity_drop() {
1620        let doc = include_str!("../doc/record-v1.md");
1621        let normalized = doc.split_whitespace().collect::<Vec<_>>().join(" ");
1622        assert!(
1623            normalized.contains("a pin file row for a voter id also pins the seat after its `/`"),
1624            "record-v1.md must say a voter-id row pins its seat"
1625        );
1626        assert!(
1627            normalized.contains("are ambiguous and are dropped on that question only"),
1628            "record-v1.md must say same-hour ambiguity is local to one question"
1629        );
1630    }
1631
1632    #[test]
1633    fn a_second_key_under_one_run_id_is_a_mismatch() {
1634        let _home = test_home();
1635        let first = sign_as(
1636            ballot_entry("alpha/run-101", "accept", "2026-10-21T10:00:00Z"),
1637            1,
1638        );
1639        let second = sign_as(
1640            ballot_entry("alpha/run-101", "reject", "2026-10-21T11:00:00Z"),
1641            2,
1642        );
1643        let err = check_entries(&[first, second]).expect_err("must reject run key mismatch");
1644        let text = err_chain(&err);
1645        assert!(text.contains("pinned"), "{text}");
1646        assert!(text.contains("none: first-seen in this check"), "{text}");
1647    }
1648
1649    #[test]
1650    fn a_seat_voter_is_pinned_to_one_key() {
1651        let _home = test_home();
1652        let first = sign_as(
1653            ballot_entry("alpha/seat-a", "accept", "2026-10-21T10:00:00Z"),
1654            1,
1655        );
1656        let second = sign_as(
1657            ballot_entry("alpha/seat-a", "reject", "2026-10-21T11:00:00Z"),
1658            2,
1659        );
1660        let err = check_entries(&[first, second]).expect_err("seat pin");
1661        assert!(err_chain(&err).contains("pinned"), "{}", err_chain(&err));
1662    }
1663
1664    #[test]
1665    fn a_question_opener_is_pinned_with_the_voter() {
1666        let _home = test_home();
1667        let question = sign_as(question_entry(), 1);
1668        let ballot = sign_as(
1669            ballot_entry("alpha/seat-a", "accept", "2026-10-21T10:00:00Z"),
1670            2,
1671        );
1672        let err = check_entries(&[question, ballot]).expect_err("opener pin");
1673        assert!(err_chain(&err).contains("pinned"), "{}", err_chain(&err));
1674    }
1675
1676    #[test]
1677    fn a_run_keeps_its_identity_and_one_key() {
1678        let _home = test_home();
1679        let err = sign_entry(
1680            ballot_entry("run/run-101", "accept", "2026-10-21T10:00:00Z"),
1681            "run/run-101",
1682        )
1683        .expect_err("bare run identity");
1684        assert!(err_chain(&err).contains("identity"), "{}", err_chain(&err));
1685        let first = sign_as(
1686            ballot_entry("alpha/run-101", "accept", "2026-10-21T10:00:00Z"),
1687            1,
1688        );
1689        let second = sign_as(
1690            ballot_entry("alpha/run-101", "reject", "2026-10-21T11:00:00Z"),
1691            2,
1692        );
1693        let err = check_entries(&[first, second]).expect_err("run pin");
1694        assert!(err_chain(&err).contains("pinned"), "{}", err_chain(&err));
1695    }
1696
1697    #[test]
1698    fn latest_ballot_per_voter_wins_and_withdrawn_does_not_count() {
1699        let _home = test_home();
1700        let a1 = sign_as(
1701            ballot_entry("alpha/seat-a", "accept", "2026-10-21T10:00:00Z"),
1702            3,
1703        );
1704        let a2 = sign_as(
1705            ballot_entry("alpha/seat-a", "withdrawn", "2026-10-21T11:00:00Z"),
1706            3,
1707        );
1708        let b1 = sign_as(
1709            ballot_entry("beta/seat-b", "accept", "2026-10-21T10:00:00Z"),
1710            4,
1711        );
1712        let read = tally(&[a1, a2, b1]).expect("tally");
1713        let by_stance = read.get("correct-3f9a1c2e").expect("question tally");
1714        assert_eq!(by_stance.get("accept"), Some(&1));
1715        assert_eq!(by_stance.get("withdrawn"), None);
1716    }
1717
1718    #[test]
1719    fn a_question_id_in_capitals_is_refused() {
1720        let _home = test_home();
1721        let at = "2026-10-21T10:00:00Z";
1722        let lower = sign_as(ballot_entry("alpha/seat-a", "accept", at), 3);
1723        let mut upper = ballot_entry("alpha/seat-a", "accept", at);
1724        let Entry::Ballot(ballot) = &mut upper.entry else {
1725            panic!("ballot");
1726        };
1727        ballot.question = "correct-3F9A1C2E".to_string();
1728        let upper = sign_unchecked(upper, 3);
1729        let entries = [lower, upper];
1730        let Err(err) = check_entries(&entries) else {
1731            panic!("one voter counted twice: {:?}", tally(&entries));
1732        };
1733        assert!(err_chain(&err).contains("lowercase"), "{}", err_chain(&err));
1734    }
1735
1736    #[test]
1737    fn two_unreplaced_ballots_in_one_hour_are_flagged() {
1738        let _home = test_home();
1739        let accept = sign_as(
1740            ballot_entry("alpha/seat-a", "accept", "2026-10-21T10:00:00Z"),
1741            3,
1742        );
1743        let reject = sign_as(
1744            ballot_entry("alpha/seat-a", "reject", "2026-10-21T10:00:00Z"),
1745            3,
1746        );
1747        let read = tally(&[accept, reject]).expect("same-hour ballots are dropped");
1748        assert!(read.is_empty(), "{read:?}");
1749    }
1750
1751    #[test]
1752    fn a_later_ballot_stands_past_a_same_hour_pair() {
1753        let _home = test_home();
1754        let cast = |stance: &str, hour: u8| {
1755            sign_as(
1756                ballot_entry("alpha/seat-a", stance, &format!("2026-10-21T{hour}:00:00Z")),
1757                3,
1758            )
1759        };
1760        let entries = [cast("accept", 10), cast("reject", 10), cast("accept", 12)];
1761        let read = tally(&entries).expect("tally");
1762        assert_eq!(
1763            counted(&read, "accept"),
1764            Some(1),
1765            "a same-hour pair hid a later ballot: {read:?}"
1766        );
1767        assert_eq!(counted(&read, "reject"), None, "{read:?}");
1768        let entries = [cast("accept", 10), cast("accept", 12), cast("reject", 12)];
1769        let read = tally(&entries).expect("tally");
1770        assert!(read.is_empty(), "a latest same-hour pair counted: {read:?}");
1771    }
1772
1773    fn counted(read: &BTreeMap<String, BTreeMap<String, usize>>, stance: &str) -> Option<usize> {
1774        read.get("correct-3f9a1c2e")
1775            .and_then(|stances| stances.get(stance))
1776            .copied()
1777    }
1778
1779    #[test]
1780    fn a_replayed_ballot_does_not_erase_its_voter() {
1781        let _home = test_home();
1782        let cast = sign_as(
1783            ballot_entry("alpha/seat-a", "accept", "2026-10-21T10:00:00Z"),
1784            3,
1785        );
1786        let mut shouted = cast.clone();
1787        shouted.signature.key = shouted.signature.key.to_ascii_uppercase();
1788        shouted.signature.sig = shouted.signature.sig.to_ascii_uppercase();
1789        let later = sign_as(
1790            ballot_entry("alpha/seat-a", "reject", "2026-10-21T12:00:00Z"),
1791            3,
1792        );
1793        for (name, entries, stance) in [
1794            ("copy", vec![cast.clone(), cast.clone()], "accept"),
1795            ("hex case", vec![cast.clone(), shouted.clone()], "accept"),
1796            (
1797                "superseded copy",
1798                vec![cast.clone(), later.clone(), cast.clone()],
1799                "reject",
1800            ),
1801        ] {
1802            let report = check_entries(&entries).unwrap_or_else(|err| panic!("{name}: {err:#}"));
1803            let read = tally(&entries).expect("tally");
1804            assert_eq!(
1805                (report.questions_with_live_ballots, counted(&read, stance)),
1806                (1, Some(1)),
1807                "a replayed ballot erased its voter ({name}): {read:?}"
1808            );
1809        }
1810    }
1811
1812    #[test]
1813    fn one_key_counts_once_on_a_question() {
1814        let _home = test_home();
1815        let entries = [
1816            sign_as(
1817                ballot_entry("alpha/seat-a", "accept", "2026-10-21T10:00:00Z"),
1818                3,
1819            ),
1820            sign_as(
1821                ballot_entry("beta/seat-b", "accept", "2026-10-21T11:00:00Z"),
1822                3,
1823            ),
1824            sign_as(
1825                ballot_entry("alpha/run-101", "reject", "2026-10-21T12:00:00Z"),
1826                3,
1827            ),
1828            sign_as(
1829                ballot_entry("gamma/seat-c", "accept", "2026-10-21T10:00:00Z"),
1830                4,
1831            ),
1832        ];
1833        let report = check_entries(&entries).expect("check");
1834        assert_eq!(report.questions_with_live_ballots, 1);
1835        let read = tally(&entries).expect("tally");
1836        assert_eq!(counted(&read, "reject"), Some(1), "{read:?}");
1837        assert_eq!(counted(&read, "accept"), Some(1), "{read:?}");
1838    }
1839
1840    #[test]
1841    fn distinct_persona_seats_with_distinct_keys_count_apart() {
1842        let _home = test_home();
1843        let entries = [
1844            sign_as(
1845                ballot_entry("ada/persona-a", "accept", "2026-10-21T10:00:00Z"),
1846                1,
1847            ),
1848            sign_as(
1849                ballot_entry("grace/persona-b", "reject", "2026-10-21T11:00:00Z"),
1850                2,
1851            ),
1852        ];
1853        let read = tally(&entries).expect("tally");
1854        assert_eq!(counted(&read, "accept"), Some(1), "{read:?}");
1855        assert_eq!(counted(&read, "reject"), Some(1), "{read:?}");
1856    }
1857
1858    #[test]
1859    fn key_links_on_another_question_do_not_merge_voters_here() {
1860        let _home = test_home();
1861        let entries = [
1862            sign_as(
1863                ballot_on_question(
1864                    "alpha/seat-a",
1865                    "accept",
1866                    "2026-10-21T10:00:00Z",
1867                    "correct-3f9a1c2e",
1868                ),
1869                1,
1870            ),
1871            sign_as(
1872                ballot_on_question(
1873                    "beta/seat-b",
1874                    "reject",
1875                    "2026-10-21T11:00:00Z",
1876                    "correct-3f9a1c2e",
1877                ),
1878                2,
1879            ),
1880            sign_as(
1881                ballot_on_question(
1882                    "gamma/seat-a",
1883                    "accept",
1884                    "2026-10-21T12:00:00Z",
1885                    "change-deadbeef",
1886                ),
1887                2,
1888            ),
1889        ];
1890        let read = tally(&entries).expect("tally");
1891        let first = read.get("correct-3f9a1c2e").expect("first question");
1892        assert_eq!(first.get("accept"), Some(&1), "{read:?}");
1893        assert_eq!(first.get("reject"), Some(&1), "{read:?}");
1894    }
1895
1896    #[test]
1897    fn spellings_of_one_voter_under_one_key_count_once() {
1898        let _home = test_home();
1899        let spellings = [
1900            "alpha/seat-a",
1901            "Alpha/seat-a",
1902            "alpha./seat-a",
1903            "alpha_/seat-a",
1904            "alpha/Seat-a",
1905            "alpha/run-101",
1906            "Alpha/run-101",
1907            "alpha./run-101",
1908            "alpha_/run-101",
1909            "alpha/inky",
1910            "alpha/inky-39u",
1911            "alpha/ljos-bot",
1912            "alpha/ljosbot",
1913            "alpha/judge-model-1-5",
1914        ];
1915        let entries: Vec<SignedEntry> = spellings
1916            .iter()
1917            .zip(1..)
1918            .map(|(voter, hour)| {
1919                let at = format!("2026-10-21T{hour:02}:00:00Z");
1920                sign_unchecked(ballot_entry(voter, "accept", &at), 3)
1921            })
1922            .collect();
1923        let read = tally(&entries).expect("tally");
1924        assert_eq!(counted(&read, "accept"), Some(1), "{read:?}");
1925    }
1926
1927    #[test]
1928    fn one_seat_under_two_keys_counts_under_neither() {
1929        let _home = test_home();
1930        let entries = [
1931            sign_as(
1932                ballot_entry("alpha/inky", "accept", "2026-10-21T10:00:00Z"),
1933                1,
1934            ),
1935            sign_as(
1936                ballot_entry("beta/inky", "reject", "2026-10-21T11:00:00Z"),
1937                2,
1938            ),
1939        ];
1940        let err = check_entries(&entries).expect_err("one seat under two keys");
1941        assert!(
1942            err_chain(&err).contains("seat inky signs under two keys"),
1943            "{}",
1944            err_chain(&err)
1945        );
1946        let read = tally(&entries).expect("tally");
1947        assert!(read.is_empty(), "{read:?}");
1948    }
1949
1950    fn orders<T: Clone>(items: &[T]) -> Vec<Vec<T>> {
1951        if items.len() <= 1 {
1952            return vec![items.to_vec()];
1953        }
1954        let mut out = Vec::new();
1955        for at in 0..items.len() {
1956            let mut rest = items.to_vec();
1957            let first = rest.remove(at);
1958            for mut tail in orders(&rest) {
1959                tail.insert(0, first.clone());
1960                out.push(tail);
1961            }
1962        }
1963        out
1964    }
1965
1966    #[test]
1967    fn a_seat_under_two_keys_reads_the_same_in_every_order() {
1968        let _home = test_home();
1969        let entries = [
1970            sign_as(
1971                ballot_entry("alpha/inky", "accept", "2026-10-21T10:00:00Z"),
1972                1,
1973            ),
1974            sign_as(
1975                ballot_entry("beta/inky", "reject", "2026-10-21T11:00:00Z"),
1976                2,
1977            ),
1978            sign_as(
1979                ballot_entry("gamma/seat-c", "accept", "2026-10-21T10:00:00Z"),
1980                3,
1981            ),
1982            sign_as(
1983                ballot_entry("delta/seat-d", "reject", "2026-10-21T11:00:00Z"),
1984                4,
1985            ),
1986        ];
1987        let read = |order: &[SignedEntry]| {
1988            let check = check_entries(order).map_err(|err| err_chain(&err));
1989            (
1990                check.map(|report| report.questions_with_live_ballots),
1991                tally(order).expect("tally"),
1992            )
1993        };
1994        let first = read(&entries);
1995        for order in orders(&entries) {
1996            assert_eq!(read(&order), first, "file order changed the result");
1997        }
1998        let (check, tallied) = first;
1999        let err = check.expect_err("a seat signed under two keys passed the check");
2000        assert!(err.contains("seat inky"), "{err}");
2001        assert_eq!(counted(&tallied, "accept"), Some(1), "{tallied:?}");
2002        assert_eq!(counted(&tallied, "reject"), Some(1), "{tallied:?}");
2003    }
2004
2005    #[test]
2006    fn a_digit_suffix_is_a_seat_of_its_own() {
2007        let _home = test_home();
2008        let entries = [
2009            sign_as(
2010                ballot_entry("alpha/inky", "accept", "2026-10-21T10:00:00Z"),
2011                1,
2012            ),
2013            sign_as(
2014                ballot_entry("beta/inky-10", "accept", "2026-10-21T11:00:00Z"),
2015                2,
2016            ),
2017        ];
2018        check_entries(&entries).expect("two seats");
2019        let read = tally(&entries).expect("tally");
2020        assert_eq!(counted(&read, "accept"), Some(2), "{read:?}");
2021    }
2022
2023    #[test]
2024    fn runs_and_judges_keep_their_ids_whole() {
2025        let _home = test_home();
2026        for (one, two) in [
2027            ("alpha/judge-gpt-oss-120b", "beta/judge-gpt-oss-20b"),
2028            ("alpha/judge-llama-3-70b", "beta/judge-llama-3-8b"),
2029            ("alpha/judge-qwen", "beta/judge-qwen-72"),
2030            ("alpha/run-pr-1234", "beta/run-pr-5678"),
2031            ("alpha/judge-grok-4", "beta/grok-4"),
2032        ] {
2033            let entries = [
2034                sign_as(ballot_entry(one, "accept", "2026-10-21T10:00:00Z"), 1),
2035                sign_as(ballot_entry(two, "accept", "2026-10-21T11:00:00Z"), 2),
2036            ];
2037            check_entries(&entries).unwrap_or_else(|err| panic!("{one} {two}: {err:#}"));
2038            let read = tally(&entries).expect("tally");
2039            assert_eq!(
2040                counted(&read, "accept"),
2041                Some(2),
2042                "{one} and {two} were merged into one voter"
2043            );
2044        }
2045    }
2046
2047    #[test]
2048    fn one_key_under_two_voters_in_one_hour_is_flagged() {
2049        let _home = test_home();
2050        let at = "2026-10-21T10:00:00Z";
2051        let entries = [
2052            sign_as(ballot_entry("alpha/seat-a", "accept", at), 3),
2053            sign_as(ballot_entry("beta/seat-b", "reject", at), 3),
2054        ];
2055        let report = check_entries(&entries).expect("one key, one-hour ambiguity");
2056        assert_eq!(report.questions_with_live_ballots, 0, "{report:?}");
2057        let read = tally(&entries).expect("tally");
2058        assert!(read.is_empty(), "{read:?}");
2059    }
2060
2061    #[test]
2062    fn a_key_in_capital_hex_is_the_same_key() {
2063        let _home = test_home();
2064        let lower = sign_as(
2065            ballot_entry("alpha/seat-a", "accept", "2026-10-21T10:00:00Z"),
2066            3,
2067        );
2068        let mut upper = sign_as(
2069            ballot_entry("beta/seat-b", "accept", "2026-10-21T11:00:00Z"),
2070            3,
2071        );
2072        upper.signature.key = upper.signature.key.to_ascii_uppercase();
2073        assert_ne!(lower.signature.key, upper.signature.key);
2074        let entries = [lower, upper];
2075        check_entries(&entries).expect("capital hex verifies");
2076        let read = tally(&entries).expect("tally");
2077        assert_eq!(counted(&read, "accept"), Some(1), "{read:?}");
2078    }
2079
2080    #[test]
2081    fn a_key_with_a_small_order_part_is_the_same_key() {
2082        let _home = test_home();
2083        let seed = [3u8; 32];
2084        let first = sign_as(
2085            ballot_entry("alpha/seat-a", "accept", "2026-10-21T10:00:00Z"),
2086            3,
2087        );
2088        let mut order_two = [0xff; 32];
2089        order_two[0] = 0xec;
2090        order_two[31] = 0x7f;
2091        let order_two = ed25519_dalek::VerifyingKey::from_bytes(&order_two).expect("point");
2092        assert!(order_two.is_weak());
2093        let public = ed25519_dalek::SigningKey::from_bytes(&seed).verifying_key();
2094        let twin = (public.to_edwards() + order_two.to_edwards())
2095            .compress()
2096            .to_bytes();
2097        let twin = ed25519_dalek::VerifyingKey::from_bytes(&twin).expect("twin");
2098        assert!(!twin.is_weak());
2099        let secret = ed25519_dalek::hazmat::ExpandedSecretKey::from(&seed);
2100        // Under the twin a signature verifies only when its challenge is even.
2101        let second = (11..24)
2102            .find_map(|hour| {
2103                let at = format!("2026-10-21T{hour}:00:00Z");
2104                let mut unsigned = ballot_entry("beta/seat-b", "accept", &at);
2105                let Entry::Ballot(ballot) = &mut unsigned.entry else {
2106                    panic!("ballot");
2107                };
2108                ballot.id = Some(ballot_id(ballot).expect("id"));
2109                let payload = signing_payload(&unsigned).expect("payload");
2110                let sig = ed25519_dalek::hazmat::raw_sign::<sha2::Sha512>(&secret, &payload, &twin);
2111                twin.verify_strict(&payload, &sig)
2112                    .is_ok()
2113                    .then(|| SignedEntry {
2114                        schema: unsigned.schema,
2115                        entry: unsigned.entry,
2116                        signature: EntrySignature {
2117                            key: hex::encode(twin.to_bytes()),
2118                            sig: hex::encode(sig.to_bytes()),
2119                        },
2120                    })
2121            })
2122            .expect("a ballot the twin key verifies");
2123        let entries = [first, second];
2124        check_entries(&entries).expect("both keys verify");
2125        let read = tally(&entries).expect("tally");
2126        assert_eq!(counted(&read, "accept"), Some(1), "{read:?}");
2127    }
2128
2129    fn with_replaces(mut entry: UnsignedEntry, id: &str) -> UnsignedEntry {
2130        let Entry::Ballot(ballot) = &mut entry.entry else {
2131            panic!("ballot");
2132        };
2133        ballot.replaces = Some(id.to_string());
2134        entry
2135    }
2136
2137    #[test]
2138    fn a_same_hour_withdrawal_does_not_count_in_either_order() {
2139        let _home = test_home();
2140        let cast = sign_as(
2141            ballot_entry("alpha/seat-a", "accept", "2026-10-21T10:00:00Z"),
2142            3,
2143        );
2144        let cast_id = as_ballot(cast.clone()).id.clone().expect("id");
2145        let withdrawal = sign_as(
2146            with_replaces(
2147                ballot_entry("alpha/seat-a", "withdrawn", "2026-10-21T10:00:00Z"),
2148                &cast_id,
2149            ),
2150            3,
2151        );
2152        for (name, order) in [
2153            (
2154                "cast then withdrawal",
2155                vec![cast.clone(), withdrawal.clone()],
2156            ),
2157            (
2158                "withdrawal then cast",
2159                vec![withdrawal.clone(), cast.clone()],
2160            ),
2161        ] {
2162            let read = tally(&order).unwrap_or_else(|err| panic!("{name}: {err:#}"));
2163            let accepts = read
2164                .get("correct-3f9a1c2e")
2165                .and_then(|stances| stances.get("accept"))
2166                .copied();
2167            assert_eq!(accepts, None, "{name}: {read:?}");
2168        }
2169        for (cast_name, withdraw_name) in [("a.json", "b.json"), ("b.json", "a.json")] {
2170            let dir = tempfile::tempdir().expect("tempdir");
2171            std::fs::write(
2172                dir.path().join(cast_name),
2173                serde_json::to_vec(&cast).expect("json"),
2174            )
2175            .expect("write cast");
2176            std::fs::write(
2177                dir.path().join(withdraw_name),
2178                serde_json::to_vec(&withdrawal).expect("json"),
2179            )
2180            .expect("write withdrawal");
2181            let report = check_dir(dir.path()).unwrap_or_else(|err| panic!("{cast_name}: {err:#}"));
2182            assert_eq!(
2183                report.questions_with_live_ballots, 0,
2184                "{cast_name} before {withdraw_name}"
2185            );
2186        }
2187    }
2188
2189    #[test]
2190    fn replaces_must_target_the_same_voter_and_question() {
2191        let _home = test_home();
2192        let missing = sign_as(
2193            with_replaces(
2194                ballot_entry("alpha/seat-a", "withdrawn", "2026-10-21T11:00:00Z"),
2195                "deadbeefdeadbeef",
2196            ),
2197            3,
2198        );
2199        let err = tally(std::slice::from_ref(&missing)).expect_err("missing replace target");
2200        assert!(
2201            err_chain(&err).contains("replaces"),
2202            "missing replace target passed: {}",
2203            err_chain(&err)
2204        );
2205
2206        let cast = sign_as(
2207            ballot_entry("alpha/seat-a", "accept", "2026-10-21T10:00:00Z"),
2208            3,
2209        );
2210        let cast_id = as_ballot(cast.clone()).id.expect("id");
2211        let same_key_other_voter = sign_as(
2212            with_replaces(
2213                ballot_entry("beta/seat-b", "withdrawn", "2026-10-21T11:00:00Z"),
2214                &cast_id,
2215            ),
2216            3,
2217        );
2218        let read = tally(&[cast.clone(), same_key_other_voter])
2219            .expect("one key is one voter across voter ids");
2220        assert_eq!(counted(&read, "accept"), None, "{read:?}");
2221
2222        let other_voter = sign_as(
2223            with_replaces(
2224                ballot_entry("beta/seat-b", "withdrawn", "2026-10-21T11:00:00Z"),
2225                &cast_id,
2226            ),
2227            4,
2228        );
2229        let err = tally(&[cast.clone(), other_voter]).expect_err("cross-voter replace");
2230        assert!(
2231            err_chain(&err).contains("same voter"),
2232            "cross-voter replace passed: {}",
2233            err_chain(&err)
2234        );
2235
2236        let mut other_question_unsigned = with_replaces(
2237            ballot_entry("alpha/seat-a", "withdrawn", "2026-10-21T11:00:00Z"),
2238            &cast_id,
2239        );
2240        let Entry::Ballot(ballot) = &mut other_question_unsigned.entry else {
2241            panic!("ballot");
2242        };
2243        ballot.question = "change-deadbeef".to_string();
2244        let other_question = sign_as(other_question_unsigned, 3);
2245        let err = tally(&[cast, other_question]).expect_err("cross-question replace");
2246        assert!(
2247            err_chain(&err).contains("same question"),
2248            "cross-question replace passed: {}",
2249            err_chain(&err)
2250        );
2251    }
2252
2253    #[test]
2254    fn check_dir_reads_and_verifies_json_entries() {
2255        let _home = test_home();
2256        let dir = tempfile::tempdir().expect("tempdir");
2257        let q = sign_as(question_entry(), 5);
2258        let b = sign_as(
2259            ballot_entry("alpha/seat-a", "accept", "2026-10-21T10:00:00Z"),
2260            5,
2261        );
2262        std::fs::write(
2263            dir.path().join("question.json"),
2264            serde_json::to_string_pretty(&q).expect("json"),
2265        )
2266        .expect("write question");
2267        std::fs::write(
2268            dir.path().join("ballot.json"),
2269            serde_json::to_string_pretty(&b).expect("json"),
2270        )
2271        .expect("write ballot");
2272        let report = check_dir(dir.path()).expect("check");
2273        assert_eq!(report.files, 2);
2274        assert_eq!(report.entries, 2);
2275        assert_eq!(report.questions, 1);
2276        assert_eq!(report.ballots, 1);
2277        assert_eq!(report.questions_with_live_ballots, 1);
2278    }
2279
2280    fn err_chain(err: &anyhow::Error) -> String {
2281        format!("{err:#}")
2282    }
2283
2284    #[test]
2285    fn timestamps_must_be_utc_hours() {
2286        let _home = test_home();
2287        for bad in [
2288            "2026-10-21T10:00:00+05:30",
2289            "2026-10-21T04:00:00-05:00",
2290            "2026-10-21T10:00:00.000Z",
2291            "2026-10-21t10:00:00z",
2292            "2026-10-21 10:00:00Z",
2293            "2026-10-21T10:30:00Z",
2294        ] {
2295            let err = sign_entry(ballot_entry("alpha/seat-a", "accept", bad), "alpha/seat-a")
2296                .expect_err(bad);
2297            let text = err_chain(&err);
2298            assert!(
2299                text.contains("YYYY-MM-DDTHH:00:00Z"),
2300                "{bad} was accepted or the error hid the form: {text}"
2301            );
2302        }
2303        let mut opened = question_entry();
2304        let Entry::Question(question) = &mut opened.entry else {
2305            panic!("question");
2306        };
2307        question.opened_at = "2026-10-21T09:00:00+00:00".to_string();
2308        let err = sign_entry(opened, "alpha/seat-a").expect_err("offset opened_at");
2309        assert!(
2310            err_chain(&err).contains("YYYY-MM-DDTHH:00:00Z"),
2311            "{}",
2312            err_chain(&err)
2313        );
2314        sign_as(
2315            ballot_entry("alpha/seat-a", "accept", "2026-10-21T10:00:00Z"),
2316            8,
2317        );
2318    }
2319
2320    #[test]
2321    fn stance_spelling_must_be_exact_lowercase() {
2322        let _home = test_home();
2323        for bad in ["accept ", "accept\u{200b}", "Accept", "WITHDRAWN"] {
2324            let err = sign_entry(
2325                ballot_entry("alpha/seat-a", bad, "2026-10-21T10:00:00Z"),
2326                "alpha/seat-a",
2327            )
2328            .expect_err(bad);
2329            assert!(
2330                err_chain(&err).contains("stance"),
2331                "{bad} passed stance validation: {}",
2332                err_chain(&err)
2333            );
2334        }
2335        for ok in ["accept", "reject", "withdrawn"] {
2336            sign_entry(
2337                ballot_entry("alpha/seat-a", ok, "2026-10-21T10:00:00Z"),
2338                "alpha/seat-a",
2339            )
2340            .unwrap_or_else(|err| panic!("{ok}: {err:#}"));
2341        }
2342    }
2343
2344    /// Reserved names and any key the signature does not cover are refused
2345    /// on the file, including inside `signature`.
2346    #[test]
2347    fn reserved_and_unsigned_fields_are_refused() {
2348        let _home = test_home();
2349        let signed = sign_as(
2350            ballot_entry("alpha/seat-a", "accept", "2026-10-21T10:00:00Z"),
2351            8,
2352        );
2353        let base = serde_json::to_value(&signed).expect("value");
2354
2355        let mut host_in_sig = base.clone();
2356        host_in_sig["signature"]["host"] = serde_json::json!("box");
2357        let err = check_value(&host_in_sig).expect_err("host inside signature");
2358        assert!(
2359            err_chain(&err).contains("host"),
2360            "reserved host was accepted: {}",
2361            err_chain(&err)
2362        );
2363
2364        let mut nested_agent = base.clone();
2365        nested_agent["signature"]["note"] = serde_json::json!({"agent": "runner"});
2366        let err = check_value(&nested_agent).expect_err("nested agent");
2367        assert!(
2368            err_chain(&err).contains("agent"),
2369            "nested agent was accepted: {}",
2370            err_chain(&err)
2371        );
2372
2373        let mut session_in_array = base.clone();
2374        session_in_array["signature"]["meta"] = serde_json::json!([{"session": "s"}]);
2375        let err = check_value(&session_in_array).expect_err("session in array");
2376        assert!(
2377            err_chain(&err).contains("session"),
2378            "session in an array was accepted: {}",
2379            err_chain(&err)
2380        );
2381
2382        let mut rationale = base.clone();
2383        rationale["rationale"] = serde_json::json!("because");
2384        let err = check_value(&rationale).expect_err("unsigned rationale");
2385        assert!(
2386            err_chain(&err).contains("does not cover"),
2387            "unsigned rationale was accepted: {}",
2388            err_chain(&err)
2389        );
2390    }
2391
2392    #[test]
2393    fn unknown_signature_fields_are_refused() {
2394        let _home = test_home();
2395        let signed = sign_as(
2396            ballot_entry("alpha/seat-a", "accept", "2026-10-21T10:00:00Z"),
2397            8,
2398        );
2399        let base = serde_json::to_value(&signed).expect("value");
2400        for (field, value) in [
2401            ("rationale", serde_json::json!("because")),
2402            ("findings", serde_json::json!(["one"])),
2403            ("Host", serde_json::json!("box")),
2404        ] {
2405            let mut changed = base.clone();
2406            changed["signature"][field] = value;
2407            let err = check_value(&changed).expect_err(field);
2408            assert!(
2409                err_chain(&err).contains(field),
2410                "{field} passed under signature: {}",
2411                err_chain(&err)
2412            );
2413        }
2414        let mut nested = base.clone();
2415        nested["signature"]["note"] = serde_json::json!({"Session": "s"});
2416        let err = check_value(&nested).expect_err("nested note.Session");
2417        assert!(
2418            err_chain(&err).contains("note"),
2419            "nested note.Session passed under signature: {}",
2420            err_chain(&err)
2421        );
2422    }
2423
2424    #[test]
2425    fn duplicate_json_keys_are_refused() {
2426        let _home = test_home();
2427        let signed = sign_as(
2428            ballot_entry("alpha/seat-a", "accept", "2026-10-21T10:00:00Z"),
2429            8,
2430        );
2431        let text = serde_json::to_string(&signed).expect("json");
2432
2433        let dup_stance = text.replacen(
2434            "\"stance\":\"accept\",",
2435            "\"stance\":\"accept\",\"stance\":\"accept\",",
2436            1,
2437        );
2438        assert_ne!(dup_stance, text, "duplicate stance fixture");
2439        let err = check_raw(&dup_stance).expect_err("duplicate stance");
2440        assert!(
2441            err_chain(&err).contains("duplicate key `stance`"),
2442            "duplicate stance was not refused as duplicate: {}",
2443            err_chain(&err)
2444        );
2445
2446        let sig = format!("\"sig\":\"{}\"", signed.signature.sig);
2447        let dup_sig = text.replacen(&sig, &format!("{sig},{sig}"), 1);
2448        assert_ne!(dup_sig, text, "duplicate signature fixture");
2449        let err = check_raw(&dup_sig).expect_err("duplicate signature");
2450        assert!(
2451            err_chain(&err).contains("duplicate key `sig`"),
2452            "duplicate signature key was not refused as duplicate: {}",
2453            err_chain(&err)
2454        );
2455    }
2456
2457    #[test]
2458    fn a_pin_file_rejects_a_different_run_key() {
2459        let _home = test_home();
2460        let root = tempfile::tempdir().expect("root");
2461        crate::with_env_mutation(|| {
2462            // SAFETY: this test holds the process environment lock.
2463            unsafe { std::env::set_var("VISSUE_ROOT", root.path()) };
2464        });
2465        let entry = sign_as(
2466            ballot_entry("alpha/run-101", "accept", "2026-10-21T10:00:00Z"),
2467            1,
2468        );
2469        let other = sign_as(
2470            ballot_entry("beta/seat-b", "reject", "2026-10-21T11:00:00Z"),
2471            2,
2472        );
2473        let line = format!("alpha/run-101 {}\n", other.signature.key);
2474        let pin_path = root.path().join(crate::ballot_sig::PIN_FILE);
2475        std::fs::write(&pin_path, line).expect("pin");
2476        let err = check_entries(std::slice::from_ref(&entry)).expect_err("pinned key");
2477        let text = err_chain(&err);
2478        assert!(text.contains("pinned"), "{text}");
2479        assert!(text.contains(pin_path.to_string_lossy().as_ref()), "{text}");
2480    }
2481
2482    #[test]
2483    fn unreadable_pin_file_is_refused() {
2484        let _home = test_home();
2485        let root = tempfile::tempdir().expect("root");
2486        crate::with_env_mutation(|| {
2487            // SAFETY: this test holds the process environment lock.
2488            unsafe { std::env::set_var("VISSUE_ROOT", root.path()) };
2489        });
2490        let pin_path = root.path().join(crate::ballot_sig::PIN_FILE);
2491        std::fs::write(&pin_path, "alpha/run-101 deadbeef\n").expect("pin");
2492        #[cfg(unix)]
2493        {
2494            use std::os::unix::fs::PermissionsExt;
2495            let mut perms = std::fs::metadata(&pin_path).expect("meta").permissions();
2496            perms.set_mode(0o000);
2497            std::fs::set_permissions(&pin_path, perms).expect("chmod");
2498        }
2499        let entry = sign_as(
2500            ballot_entry("alpha/run-101", "accept", "2026-10-21T10:00:00Z"),
2501            1,
2502        );
2503        let err = check_entries(std::slice::from_ref(&entry)).expect_err("unreadable pin file");
2504        assert!(
2505            err_chain(&err).contains(pin_path.to_string_lossy().as_ref()),
2506            "{}",
2507            err_chain(&err)
2508        );
2509    }
2510
2511    #[test]
2512    fn a_dangling_pin_file_link_is_refused() {
2513        let _home = test_home();
2514        let root = tempfile::tempdir().expect("root");
2515        crate::with_env_mutation(|| {
2516            // SAFETY: this test holds the process environment lock.
2517            unsafe { std::env::set_var("VISSUE_ROOT", root.path()) };
2518        });
2519        let pin_path = root.path().join(crate::ballot_sig::PIN_FILE);
2520        std::os::unix::fs::symlink(root.path().join("gone"), &pin_path).expect("symlink");
2521        let entry = sign_as(
2522            ballot_entry("alpha/run-101", "accept", "2026-10-21T10:00:00Z"),
2523            1,
2524        );
2525        let err = check_entries(std::slice::from_ref(&entry))
2526            .expect_err("a dangling pin file link read as no pin file");
2527        assert!(
2528            err_chain(&err).contains(pin_path.to_string_lossy().as_ref()),
2529            "{}",
2530            err_chain(&err)
2531        );
2532    }
2533
2534    #[test]
2535    fn duplicate_pin_voter_lines_are_refused() {
2536        let _home = test_home();
2537        let root = tempfile::tempdir().expect("root");
2538        crate::with_env_mutation(|| {
2539            // SAFETY: this test holds the process environment lock.
2540            unsafe { std::env::set_var("VISSUE_ROOT", root.path()) };
2541        });
2542        let entry = sign_as(
2543            ballot_entry("alpha/run-101", "accept", "2026-10-21T10:00:00Z"),
2544            1,
2545        );
2546        let line = format!(
2547            "alpha/run-101 {}\nalpha/run-101 {}\n",
2548            entry.signature.key, entry.signature.key
2549        );
2550        std::fs::write(root.path().join(crate::ballot_sig::PIN_FILE), line).expect("pin");
2551        let err = check_entries(std::slice::from_ref(&entry)).expect_err("duplicate pin voter");
2552        assert!(err_chain(&err).contains("duplicate"), "{}", err_chain(&err));
2553    }
2554
2555    #[test]
2556    fn a_seat_voter_must_match_the_tracker_seat_pin() {
2557        let _home = test_home();
2558        let root = tempfile::tempdir().expect("root");
2559        crate::with_env_mutation(|| {
2560            // SAFETY: this test holds the process environment lock.
2561            unsafe { std::env::set_var("VISSUE_ROOT", root.path()) };
2562        });
2563        let pinned = sign_as(
2564            ballot_entry("beta/seat-b", "accept", "2026-10-21T10:00:00Z"),
2565            1,
2566        );
2567        let entry = sign_as(
2568            ballot_entry("alpha/seat-a", "accept", "2026-10-21T10:00:00Z"),
2569            2,
2570        );
2571        let line = format!("seat-a {}\n", pinned.signature.key);
2572        std::fs::write(root.path().join(crate::ballot_sig::PIN_FILE), line).expect("pin");
2573        let err = check_entries(std::slice::from_ref(&entry)).expect_err("seat pin mismatch");
2574        assert!(err_chain(&err).contains("seat-a"), "{}", err_chain(&err));
2575    }
2576
2577    #[test]
2578    fn a_voter_pin_row_cannot_give_a_seat_a_second_key() {
2579        let _home = test_home();
2580        let root = tempfile::tempdir().expect("root");
2581        crate::with_env_mutation(|| {
2582            // SAFETY: this test holds the process environment lock.
2583            unsafe { std::env::set_var("VISSUE_ROOT", root.path()) };
2584        });
2585        let at = "2026-10-21T10:00:00Z";
2586        let owner = sign_as(ballot_entry("alpha/inky", "accept", at), 1);
2587        let mallory = sign_as(ballot_entry("mallory/inky", "reject", at), 9);
2588        let (k1, k9) = (&owner.signature.key, &mallory.signature.key);
2589        let pin_path = root.path().join(crate::ballot_sig::PIN_FILE);
2590        for rows in [
2591            format!("inky {k1}\nmallory/inky {k9}\n"),
2592            format!("alpha/inky {k1}\nmallory/inky {k9}\n"),
2593        ] {
2594            std::fs::write(&pin_path, &rows).expect("pin");
2595            let Err(err) = check_entries(std::slice::from_ref(&mallory)) else {
2596                panic!("a second key voted under a pinned seat: {rows}");
2597            };
2598            assert!(
2599                err_chain(&err).contains("seat inky is pinned to two keys"),
2600                "{}",
2601                err_chain(&err)
2602            );
2603        }
2604    }
2605
2606    #[test]
2607    fn a_seat_pin_accepts_same_key_in_capital_hex() {
2608        let _home = test_home();
2609        let root = tempfile::tempdir().expect("root");
2610        crate::with_env_mutation(|| {
2611            // SAFETY: this test holds the process environment lock.
2612            unsafe { std::env::set_var("VISSUE_ROOT", root.path()) };
2613        });
2614        let pinned = sign_as(
2615            ballot_entry("beta/seat-b", "accept", "2026-10-21T10:00:00Z"),
2616            1,
2617        );
2618        let mut entry = sign_as(
2619            ballot_entry("alpha/seat-a", "accept", "2026-10-21T11:00:00Z"),
2620            1,
2621        );
2622        let pin_path = root.path().join(crate::ballot_sig::PIN_FILE);
2623        std::fs::write(&pin_path, format!("seat-a {}\n", pinned.signature.key)).expect("pin");
2624        entry.signature.key = entry.signature.key.to_ascii_uppercase();
2625        check_entries(std::slice::from_ref(&entry))
2626            .unwrap_or_else(|err| panic!("seat pin should match key hex case: {err:#}"));
2627    }
2628
2629    #[test]
2630    fn a_pin_voter_that_is_not_canonical_is_refused() {
2631        let _home = test_home();
2632        let root = tempfile::tempdir().expect("root");
2633        crate::with_env_mutation(|| {
2634            // SAFETY: this test holds the process environment lock.
2635            unsafe { std::env::set_var("VISSUE_ROOT", root.path()) };
2636        });
2637        let pinned = sign_as(
2638            ballot_entry("beta/seat-b", "accept", "2026-10-21T10:00:00Z"),
2639            1,
2640        );
2641        let entry = sign_as(
2642            ballot_entry("alpha/seat-a", "accept", "2026-10-21T10:00:00Z"),
2643            2,
2644        );
2645        let key = &pinned.signature.key;
2646        let pin_path = root.path().join(crate::ballot_sig::PIN_FILE);
2647        for voter in [
2648            "\u{feff}seat-a",
2649            "\u{200b}seat-a",
2650            "Seat-a",
2651            "seat_a",
2652            "Alpha/run-101",
2653        ] {
2654            std::fs::write(&pin_path, format!("{voter} {key}\n")).expect("pin");
2655            let Err(err) = check_entries(std::slice::from_ref(&entry)) else {
2656                panic!("the {voter:?} pin row was skipped");
2657            };
2658            assert!(
2659                err_chain(&err).contains("pin voter"),
2660                "{voter:?}: {}",
2661                err_chain(&err)
2662            );
2663        }
2664    }
2665
2666    #[test]
2667    fn holder_judge_and_legacy_pin_rows_hold_their_seat() {
2668        let _home = test_home();
2669        let root = tempfile::tempdir().expect("root");
2670        crate::with_env_mutation(|| {
2671            // SAFETY: this test holds the process environment lock.
2672            unsafe { std::env::set_var("VISSUE_ROOT", root.path()) };
2673        });
2674        let at = "2026-10-21T10:00:00Z";
2675        let key = sign_as(ballot_entry("beta/seat-b", "accept", at), 1)
2676            .signature
2677            .key;
2678        let rows: String = [
2679            "inky@39u",
2680            "disk-saver-t4t",
2681            "ljos-bot",
2682            "judge:model-1.5",
2683            "seat-b",
2684            "alpha/run-101",
2685        ]
2686        .iter()
2687        .map(|row| format!("{row} {key}\n"))
2688        .collect();
2689        std::fs::write(root.path().join(crate::ballot_sig::PIN_FILE), rows).expect("pin");
2690        for (voter, seat) in [
2691            ("alpha/inky", "inky"),
2692            ("alpha/disk-saver", "disk-saver"),
2693            ("alpha/ljosbot", "ljosbot"),
2694            ("alpha/judge-model-1-5", "judge-model-1-5"),
2695        ] {
2696            let other = sign_as(ballot_entry(voter, "accept", at), 2);
2697            let err = check_entries(std::slice::from_ref(&other))
2698                .expect_err("a second key voted under a pinned seat's alias");
2699            assert!(
2700                err_chain(&err).contains(&format!("{voter} is pinned through seat {seat}")),
2701                "{voter}: {}",
2702                err_chain(&err)
2703            );
2704            let held = sign_as(ballot_entry(voter, "accept", at), 1);
2705            check_entries(std::slice::from_ref(&held))
2706                .unwrap_or_else(|err| panic!("{voter} under its pinned key: {err:#}"));
2707        }
2708        let free = sign_as(ballot_entry("alpha/seat-a", "accept", at), 2);
2709        check_entries(std::slice::from_ref(&free)).expect("no row folds to seat-a");
2710    }
2711
2712    #[test]
2713    fn a_seat_pin_does_not_hold_a_run_or_judge_of_that_name() {
2714        let _home = test_home();
2715        let root = tempfile::tempdir().expect("root");
2716        crate::with_env_mutation(|| {
2717            // SAFETY: this test holds the process environment lock.
2718            unsafe { std::env::set_var("VISSUE_ROOT", root.path()) };
2719        });
2720        let pinned = sign_as(
2721            ballot_entry("beta/seat-b", "accept", "2026-10-21T10:00:00Z"),
2722            1,
2723        );
2724        std::fs::write(
2725            root.path().join(crate::ballot_sig::PIN_FILE),
2726            format!("inky {}\n", pinned.signature.key),
2727        )
2728        .expect("pin");
2729        for voter in ["alpha/run-inky", "alpha/judge-inky"] {
2730            let other = sign_as(ballot_entry(voter, "accept", "2026-10-21T11:00:00Z"), 2);
2731            check_entries(std::slice::from_ref(&other))
2732                .unwrap_or_else(|err| panic!("{voter} was held to seat inky: {err:#}"));
2733        }
2734    }
2735
2736    #[test]
2737    fn a_seat_pinned_to_two_keys_under_two_names_is_refused() {
2738        let _home = test_home();
2739        let root = tempfile::tempdir().expect("root");
2740        crate::with_env_mutation(|| {
2741            // SAFETY: this test holds the process environment lock.
2742            unsafe { std::env::set_var("VISSUE_ROOT", root.path()) };
2743        });
2744        let pin_path = root.path().join(crate::ballot_sig::PIN_FILE);
2745        let at = "2026-10-21T10:00:00Z";
2746        let bare = sign_as(ballot_entry("alpha/inky", "accept", at), 1);
2747        let tagged = sign_as(ballot_entry("beta/inky", "accept", at), 2);
2748        let (key1, key2) = (&bare.signature.key, &tagged.signature.key);
2749        std::fs::write(&pin_path, format!("inky {key1}\ninky@39u {key2}\n")).expect("pin");
2750        let entries = [bare.clone(), tagged.clone()];
2751        let Err(err) = check_entries(&entries) else {
2752            panic!("one seat voted under two keys: {:?}", tally(&entries));
2753        };
2754        let text = err_chain(&err);
2755        assert!(text.contains("seat inky is pinned to two keys"), "{text}");
2756        let read = tally(&entries).expect("one seat under two keys");
2757        assert!(read.is_empty(), "{read:?}");
2758        let later = sign_as(
2759            ballot_entry("beta/inky", "accept", "2026-10-21T11:00:00Z"),
2760            2,
2761        );
2762        let read = tally(&[later, bare.clone()]).expect("tally");
2763        assert!(read.is_empty(), "{read:?}");
2764        std::fs::write(&pin_path, format!("inky {key1}\ninky@39u {key1}\n")).expect("pin");
2765        check_entries(std::slice::from_ref(&bare)).expect("one key under both names");
2766        let err = check_entries(std::slice::from_ref(&tagged)).expect_err("an unpinned key");
2767        let text = err_chain(&err);
2768        assert!(
2769            text.contains("(inky)") && text.contains("(inky@39u)"),
2770            "{text}"
2771        );
2772    }
2773
2774    #[test]
2775    fn a_run_seat_with_a_pin_row_is_held_to_it() {
2776        let _home = test_home();
2777        let root = tempfile::tempdir().expect("root");
2778        crate::with_env_mutation(|| {
2779            // SAFETY: this test holds the process environment lock.
2780            unsafe { std::env::set_var("VISSUE_ROOT", root.path()) };
2781        });
2782        let pinned = sign_as(
2783            ballot_entry("beta/seat-b", "accept", "2026-10-21T10:00:00Z"),
2784            1,
2785        );
2786        let entry = sign_as(
2787            ballot_entry("alpha/run-101", "accept", "2026-10-21T10:00:00Z"),
2788            2,
2789        );
2790        let line = format!("run-101 {}\n", pinned.signature.key);
2791        std::fs::write(root.path().join(crate::ballot_sig::PIN_FILE), line).expect("pin");
2792        let err = check_entries(std::slice::from_ref(&entry))
2793            .expect_err("alpha/run-101 passed beside a run-101 pin row");
2794        assert!(
2795            err_chain(&err).contains("through seat run-101"),
2796            "{}",
2797            err_chain(&err)
2798        );
2799    }
2800
2801    #[test]
2802    fn a_voter_seat_part_must_match_its_slug() {
2803        let _home = test_home();
2804        let root = tempfile::tempdir().expect("root");
2805        crate::with_env_mutation(|| {
2806            // SAFETY: this test holds the process environment lock.
2807            unsafe { std::env::set_var("VISSUE_ROOT", root.path()) };
2808        });
2809        let pinned = sign_as(
2810            ballot_entry("alpha/seat-a", "accept", "2026-10-21T10:00:00Z"),
2811            1,
2812        );
2813        let line = format!("seat-a {}\n", pinned.signature.key);
2814        std::fs::write(root.path().join(crate::ballot_sig::PIN_FILE), line).expect("pin");
2815        let other = sign_unchecked(
2816            ballot_entry("alpha/Seat-a", "accept", "2026-10-21T11:00:00Z"),
2817            1,
2818        );
2819        let entries = [pinned, other];
2820        match check_entries(&entries) {
2821            Ok(_) => panic!(
2822                "alpha/Seat-a passed beside alpha/seat-a: {:?}",
2823                tally(&entries)
2824            ),
2825            Err(err) => {
2826                let text = err_chain(&err);
2827                assert!(text.contains("must be written `alpha/seat-a`"), "{text}");
2828            }
2829        }
2830    }
2831
2832    fn check_value(value: &serde_json::Value) -> anyhow::Result<CheckReport> {
2833        let dir = tempfile::tempdir().expect("tempdir");
2834        std::fs::write(
2835            dir.path().join("entry.json"),
2836            serde_json::to_vec(value).expect("json"),
2837        )
2838        .expect("write");
2839        check_dir(dir.path())
2840    }
2841
2842    fn check_raw(raw: &str) -> anyhow::Result<CheckReport> {
2843        let dir = tempfile::tempdir().expect("tempdir");
2844        std::fs::write(dir.path().join("entry.json"), raw).expect("write");
2845        check_dir(dir.path())
2846    }
2847}