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