1use 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
12pub 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#[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#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
39pub struct UnsignedEntry {
40 pub schema: String,
41 #[serde(flatten)]
42 pub entry: Entry,
43}
44
45#[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#[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#[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#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
78pub struct BallotEntry {
79 #[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#[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
109pub 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
148pub 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
173pub 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
229fn 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
248fn 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
278fn 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
309fn 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
335pub 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#[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#[derive(Debug, Clone, PartialEq, Eq)]
374pub struct BadBallot {
375 pub file: PathBuf,
377 pub line: usize,
379 pub issue: String,
380 pub reason: String,
381}
382
383#[derive(Debug, Clone, Default, PartialEq, Eq)]
385pub struct TrackerReport {
386 pub issues: usize,
388 pub ballots: usize,
389 pub legacy: Vec<String>,
391 pub bad: Vec<BadBallot>,
392}
393
394pub 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, ¬es, &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
482fn 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
513fn 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
536fn ballot_voter(line: &str) -> Option<&str> {
538 let (_, rest) = line.strip_prefix('[')?.split_once("] ")?;
539 Some(rest.split_once(": ")?.0.trim())
540}
541
542#[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
578pub fn canonical_json(value: &Value) -> Result<Vec<u8>> {
584 Ok(serde_json::to_vec(&canonical_value(value))?)
585}
586
587pub 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
603pub 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 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 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
899fn 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
909fn 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 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
1073fn 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 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 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 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 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 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 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 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 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 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 #[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 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 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 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 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 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 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 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 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 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 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 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 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 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}