Skip to main content

ljos_cli/
panel_gate.rs

1//! A ballot records the persona, the seat or session that cast it, and
2//! the parent session. Ballots that share a session id, a parent, a
3//! calling shell or a harness root are all recorded. They count as one
4//! voice, the latest when they disagree. Consensus names the ballots it
5//! folded and why. A subagent of that root counts with its parent.
6//! A session that filed a design candidate does not also vote.
7//!
8//! A design candidate whose session had already read another candidate is
9//! flagged: the round was not blind.
10
11use anyhow::{bail, Result};
12use serde_json::Value;
13use std::collections::BTreeMap;
14use std::path::{Path, PathBuf};
15use std::process::{Command, Stdio};
16
17use crate::ballot_sig;
18use crate::proc_tree;
19
20/// The read log at the tracker root: one line
21/// `TIME HARNESS NONCE ISSUE` per recall.
22pub const READS_FILE: &str = ".ljos-reads";
23
24/// How a ballot traces to a session. An empty harness and nonce is a
25/// legacy ballot from before binding, and the gate leaves those alone
26/// when every ballot is unbound.
27#[derive(Debug, Clone, PartialEq, Eq)]
28pub struct BallotTrace {
29    pub voter: String,
30    pub harness: String,
31    pub nonce: String,
32}
33
34/// A design candidate a session filed, and the other candidates that
35/// session had already read.
36#[derive(Debug, Clone, PartialEq, Eq)]
37pub struct CandidateTrace {
38    pub id: String,
39    pub harness: String,
40    pub nonce: String,
41    pub saw: Vec<String>,
42}
43
44/// The session a binding counts as. A harness id groups every process
45/// that inherited it, including a worker that spawned one `ljos vote` per
46/// seat. With no harness id, the process nonce is the session, so one
47/// process still cannot mint several seats.
48#[must_use]
49pub fn session_key(harness: &str, nonce: &str) -> String {
50    let h = harness.trim();
51    if h.is_empty() || h == "-" {
52        let n = nonce.trim();
53        if n.is_empty() || n == "-" {
54            String::new()
55        } else {
56            format!("nonce:{n}")
57        }
58    } else {
59        h.to_string()
60    }
61}
62
63/// Why a settle is refused, or `None` when no author of a candidate also
64/// voted. Persona ballots that share a parent are counted, not refused.
65/// The sentence is the whole refusal.
66#[must_use]
67pub fn refusal(
68    issue: &str,
69    ballots: &[BallotTrace],
70    candidates: &[CandidateTrace],
71) -> Option<String> {
72    let mut groups: BTreeMap<String, Vec<String>> = BTreeMap::new();
73    for b in ballots {
74        let key = session_key(&b.harness, &b.nonce);
75        if key.is_empty() {
76            continue;
77        }
78        groups.entry(key).or_default().push(b.voter.clone());
79    }
80    let mut authored = Vec::new();
81    for c in candidates {
82        let key = session_key(&c.harness, &c.nonce);
83        if key.is_empty() || !groups.contains_key(&key) {
84            continue;
85        }
86        authored.push(format!(
87            "session {key} authored candidate {} and voted on {issue}",
88            c.id
89        ));
90    }
91    authored.sort();
92    if authored.is_empty() {
93        return None;
94    }
95    Some(format!(
96        "refuses to settle {issue}: {}",
97        authored.join("; ")
98    ))
99}
100
101/// The flag for a candidate whose session read another candidate first.
102#[must_use]
103pub fn blind_sentence(id: &str, session: &str, saw: &[String]) -> String {
104    format!(
105        "design {id} is not blind: session {session} read {} before filing it",
106        saw.join(", ")
107    )
108}
109
110/// Flags for candidates that were not filed blind. A candidate with an
111/// empty read list is quiet.
112#[must_use]
113pub fn blind_lines(candidates: &[CandidateTrace]) -> Vec<String> {
114    let mut out = Vec::new();
115    for c in candidates {
116        if c.saw.is_empty() {
117            continue;
118        }
119        let session = session_key(&c.harness, &c.nonce);
120        let session = if session.is_empty() {
121            c.harness.clone()
122        } else {
123            session
124        };
125        out.push(blind_sentence(&c.id, &session, &c.saw));
126    }
127    out
128}
129
130/// Issue ids in `reads` (session key, issue) that are other candidates.
131#[must_use]
132pub fn reads_that_spoil(
133    session: &str,
134    reads: &[(String, String)],
135    others: &[String],
136) -> Vec<String> {
137    let mut saw: Vec<String> = reads
138        .iter()
139        .filter(|(key, issue)| key == session && others.iter().any(|o| o == issue))
140        .map(|(_, issue)| issue.clone())
141        .collect();
142    saw.sort();
143    saw.dedup();
144    saw
145}
146
147fn newest_sig(notes: &[String], voter: &str) -> Option<ballot_sig::SigNote> {
148    notes.iter().find_map(|n| {
149        let note = ballot_sig::parse_note(n)?;
150        (note.voter == voter).then_some(note)
151    })
152}
153
154/// Traces for the ballots on an issue, from its signature notes. A ballot
155/// with no v2 note is unbound.
156#[must_use]
157pub fn ballot_traces(ballots: &[Value], notes: &[String]) -> Vec<BallotTrace> {
158    ballots
159        .iter()
160        .filter_map(|b| {
161            let voter = b["agent"].as_str()?.trim().to_string();
162            if voter.is_empty() {
163                return None;
164            }
165            let (harness, nonce) = newest_sig(notes, &voter)
166                .map(|n| (n.harness.unwrap_or_default(), n.nonce.unwrap_or_default()))
167                .unwrap_or_default();
168            Some(BallotTrace {
169                voter,
170                harness,
171                nonce,
172            })
173        })
174        .collect()
175}
176
177fn verified_candidate(note: &str) -> Option<CandidateTrace> {
178    let parsed = ballot_sig::parse_candidate(note)?;
179    if !ballot_sig::candidate_verifies(&parsed) {
180        return None;
181    }
182    Some(CandidateTrace {
183        id: parsed.issue,
184        harness: parsed.harness,
185        nonce: parsed.nonce,
186        saw: parsed.saw,
187    })
188}
189
190/// Candidate bindings in `notes`, signatures checked.
191#[must_use]
192pub fn candidates_in(notes: &[String]) -> Vec<CandidateTrace> {
193    let mut out = Vec::new();
194    let mut seen = std::collections::BTreeSet::new();
195    for note in notes {
196        if let Some(c) = verified_candidate(note) {
197            if seen.insert(c.id.clone()) {
198                out.push(c);
199            }
200        }
201    }
202    out
203}
204
205fn parent_of(v: &Value) -> Option<String> {
206    v.get("parent")
207        .or_else(|| v.get("PARENT"))
208        .and_then(Value::as_str)
209        .map(str::trim)
210        .filter(|s| !s.is_empty())
211        .map(str::to_string)
212}
213
214fn children_of(issue: &str) -> Vec<String> {
215    let Ok(said) = crate::tracker_listing(&["children", issue, "--json"]) else {
216        return Vec::new();
217    };
218    serde_json::from_str::<Vec<Value>>(&said)
219        .ok()
220        .into_iter()
221        .flatten()
222        .filter_map(|row| row["id"].as_str().map(str::to_string))
223        .collect()
224}
225
226fn family_issue_ids(issue: &str) -> Vec<String> {
227    let mut ids = vec![issue.to_string()];
228    if let Ok(v) = crate::issue_record(issue) {
229        if let Some(p) = parent_of(&v) {
230            ids.push(p);
231        }
232    }
233    let mut extra = Vec::new();
234    for id in &ids {
235        extra.extend(children_of(id));
236    }
237    ids.extend(extra);
238    ids.sort();
239    ids.dedup();
240    ids
241}
242
243fn notes_on(issue: &str) -> Vec<String> {
244    crate::issue_record(issue)
245        .map(|v| ballot_sig::notes_of(&v))
246        .unwrap_or_default()
247}
248
249/// Notes that carry candidate bindings for `issue`: its own, its parent's,
250/// and each design child's.
251fn candidate_notes(issue: &str) -> Vec<String> {
252    let mut notes = Vec::new();
253    for id in family_issue_ids(issue) {
254        notes.extend(notes_on(&id));
255    }
256    notes
257}
258
259fn family_notes(issue: &str) -> Vec<(String, String)> {
260    family_issue_ids(issue)
261        .into_iter()
262        .filter(|id| id != issue)
263        .flat_map(|id| {
264            notes_on(&id)
265                .into_iter()
266                .map(move |note| (id.clone(), note))
267        })
268        .collect()
269}
270
271/// Per `(issue, voter)`, where in `notes` (newest first) its newest
272/// `ballot-withdrawn:` note sits. A note with no issue keeps the legacy
273/// meaning: it applies to the issue whose logbook holds it (`home`).
274fn withdrawal_cuts(notes: &[String], home: &str) -> BTreeMap<(String, String), usize> {
275    let mut cuts = BTreeMap::new();
276    for (at, note) in notes.iter().enumerate() {
277        if let Some(w) = ballot_sig::parse_withdrawn(note) {
278            let issue = w.issue.unwrap_or_else(|| home.to_string());
279            cuts.entry((issue, w.voter)).or_insert(at);
280        }
281    }
282    cuts
283}
284
285fn withdrawn_at(
286    cuts: &BTreeMap<(String, String), usize>,
287    issue: &str,
288    voter: &str,
289) -> Option<usize> {
290    if issue.is_empty() {
291        let field = ballot_sig::field(voter);
292        return cuts
293            .iter()
294            .find_map(|((_issue, v), at)| (v == voter || v == &field).then_some(*at));
295    }
296    let named = (issue.to_string(), voter.to_string());
297    let field = (issue.to_string(), ballot_sig::field(voter));
298    cuts.get(&named).or_else(|| cuts.get(&field)).copied()
299}
300
301fn same_voter(signed: &str, voter: &str) -> bool {
302    signed == voter.trim() || signed == ballot_sig::field(voter)
303}
304
305fn matching_ballot_issue(
306    issue: &str,
307    voter: &str,
308    own: &[String],
309    family: &[(String, String)],
310) -> Option<String> {
311    if voter.trim().is_empty() {
312        return None;
313    }
314    let mut by_issue: BTreeMap<String, Vec<String>> = BTreeMap::new();
315    for (id, note) in family {
316        by_issue.entry(id.clone()).or_default().push(note.clone());
317    }
318    for (home, notes) in std::iter::once((issue.to_string(), own.to_vec())).chain(by_issue) {
319        let cuts = withdrawal_cuts(&notes, &home);
320        for (at, note) in notes.iter().enumerate() {
321            let Some(sig) = ballot_sig::parse_note(note) else {
322                continue;
323            };
324            if !ballot_sig::verifies(issue, &sig) {
325                continue;
326            }
327            if !same_voter(&sig.voter, voter) {
328                continue;
329            }
330            if withdrawn_at(&cuts, issue, &sig.voter).is_some_and(|cut| cut < at) {
331                continue;
332            }
333            return Some(home);
334        }
335    }
336    None
337}
338
339/// How `voter` replaces the ballot it holds on `issue`.
340#[must_use]
341pub fn replace_hint(issue: &str, voter: &str) -> String {
342    let voter = ballot_sig::field(voter);
343    format!(
344        "To replace it, withdraw it and cast again: `ljos vote {issue} --withdraw --as {voter}` (ljos_vote with withdraw), then `ljos vote {issue} --for OPTION --used ... --as {voter}`."
345    )
346}
347
348/// Why this connection must not cast on `issue`. `own` is that issue's
349/// logbook. `family` is the parent and the design children. A signature
350/// counts from either, but only when it verifies for `issue`: a ballot on
351/// a sibling is another question. A withdrawal is read from whichever
352/// logbook carries that signature.
353/// candidate counts from either. The session key is the connection. The
354/// voter name is not it.
355#[must_use]
356fn session_cast_refusal_on(
357    issue: &str,
358    key: &str,
359    voter: &str,
360    own: &[String],
361    family: &[(String, String)],
362) -> Option<String> {
363    let key = key.trim();
364    if key.is_empty() {
365        return Some(format!(
366            "a ballot on {issue} needs this connection's session. Nothing was cast"
367        ));
368    }
369    let mut family_by_issue: BTreeMap<String, Vec<String>> = BTreeMap::new();
370    for (id, note) in family {
371        family_by_issue
372            .entry(id.clone())
373            .or_default()
374            .push(note.clone());
375    }
376    let signed = own
377        .iter()
378        .enumerate()
379        .map(|(at, note)| (issue, Some(at), note));
380    let elsewhere = family_by_issue.iter().flat_map(|(home, notes)| {
381        notes
382            .iter()
383            .enumerate()
384            .map(move |(at, note)| (home.as_str(), Some(at), note))
385    });
386    for (home, at, note) in signed.chain(elsewhere) {
387        let Some(sig) = ballot_sig::parse_note(note) else {
388            continue;
389        };
390        if !ballot_sig::verifies(issue, &sig) {
391            continue;
392        }
393        let other = session_key(
394            sig.harness.as_deref().unwrap_or(""),
395            sig.nonce.as_deref().unwrap_or(""),
396        );
397        // Another persona from this session is recorded. The same voter
398        // replaces a ballot by withdrawing it first.
399        if other != key || !same_voter(&sig.voter, voter) {
400            continue;
401        }
402        let session = if sig.seat_only() {
403            format!("{key}, {}", ballot_sig::unverified_sentence(key))
404        } else {
405            key.to_string()
406        };
407        let cuts = if home == issue {
408            withdrawal_cuts(own, issue)
409        } else {
410            let notes = family_by_issue.get(home).cloned().unwrap_or_default();
411            withdrawal_cuts(&notes, home)
412        };
413        if at.is_some_and(|at| withdrawn_at(&cuts, issue, &sig.voter).is_some_and(|cut| cut < at)) {
414            continue;
415        }
416        return Some(format!(
417            "this session already cast a ballot on {issue} ({session}): {}'s, for {}. Nothing was cast. {}",
418            sig.voter,
419            sig.choice,
420            replace_hint(issue, voter)
421        ));
422    }
423    for note in own.iter().chain(family.iter().map(|(_, note)| note)) {
424        let Some(candidate) = ballot_sig::parse_candidate(note) else {
425            continue;
426        };
427        let other = session_key(&candidate.harness, &candidate.nonce);
428        if other == key {
429            return Some(format!(
430                "this session authored candidate {} and its vote on {issue} is refused. Nothing was cast.",
431                candidate.issue
432            ));
433        }
434    }
435    None
436}
437
438/// Why this connection must not cast on `issue`. `own` is that issue's
439/// logbook. `family` is the parent and the design children.
440#[must_use]
441pub fn session_cast_refusal(
442    issue: &str,
443    key: &str,
444    voter: &str,
445    own: &[String],
446    family: &[(String, String)],
447) -> Option<String> {
448    let family: Vec<(String, String)> = family
449        .iter()
450        .filter(|(_, note)| !own.contains(note))
451        .cloned()
452        .collect();
453    session_cast_refusal_on(issue, key, voter, own, &family)
454}
455
456/// The issue in this issue family where `voter` still has this session's
457/// ballot on `issue`, if any.
458#[must_use]
459pub fn standing_ballot_issue(issue: &str, voter: &str) -> Option<String> {
460    let own = notes_on(issue);
461    let family = family_notes(issue);
462    matching_ballot_issue(issue, voter, &own, &family)
463}
464
465static CAST: std::sync::Mutex<std::collections::BTreeSet<String>> =
466    std::sync::Mutex::new(std::collections::BTreeSet::new());
467
468/// The processes a cast on this machine runs under, each a
469/// [`proc_tree::token`]: the shell that ran this `ljos`, and the harness
470/// whose hook saw its line. The environment names the session; it cannot
471/// cheaply change either of these.
472#[derive(Debug, Default, Clone, PartialEq, Eq)]
473struct CastTree {
474    shell: Option<String>,
475    harness: Option<String>,
476}
477
478impl CastTree {
479    /// This process's tree. Only the `ljos` executable has one, matched by
480    /// its resolved path, so a link or another argv0 still binds. The MCP
481    /// server answers a parent and its subagents alike, and names each
482    /// call's session itself.
483    fn here() -> Self {
484        let mine = proc_tree::exe(std::process::id());
485        if !mine
486            .as_ref()
487            .is_some_and(|path| proc_tree::is_ljos_exe(path))
488        {
489            return Self::default();
490        }
491        let chain = proc_tree::ancestors();
492        Self {
493            shell: proc_tree::cast_shell(&chain, mine.as_deref()).map(proc_tree::token),
494            harness: crate::subagent::taken_harness().map(|p| proc_tree::token(&p)),
495        }
496    }
497}
498
499/// One cast recorded under `ballot-trees/ISSUE` in the runtime directory:
500/// `SHELL HARNESS SESSION STATE`, tab-separated, `-` for a process not
501/// known. The state is `cast`, or the token of the process that holds the
502/// claim until its cast lands.
503#[derive(Debug, Clone, PartialEq, Eq)]
504struct TreeRow {
505    shell: String,
506    harness: String,
507    session: String,
508    state: String,
509}
510
511const CAST_STATE: &str = "cast";
512
513impl TreeRow {
514    fn parse(line: &str) -> Option<Self> {
515        let mut f = line.split('\t');
516        let row = Self {
517            shell: f.next()?.to_string(),
518            harness: f.next()?.to_string(),
519            session: f.next()?.to_string(),
520            state: f.next()?.to_string(),
521        };
522        f.next().is_none().then_some(row)
523    }
524
525    fn line(&self) -> String {
526        format!(
527            "{}\t{}\t{}\t{}",
528            self.shell, self.harness, self.session, self.state
529        )
530    }
531
532    /// Whether the row can still bind a cast: a process it names runs,
533    /// and its claim was kept or its holder is still at it.
534    fn live(&self) -> bool {
535        let runs = |t: &str| t != "-" && proc_tree::token_alive(t);
536        (runs(&self.shell) || runs(&self.harness))
537            && (self.state == CAST_STATE || runs(&self.state))
538    }
539
540    /// The earlier voice when a cast as `key` under `tree` repeats this
541    /// row. A shell runs one agent's commands, so any other session from
542    /// it is that voice. A runner row binds another root session. A
543    /// subagent of the same root is not bound here, and the parent fold
544    /// counts it with that root. The pair is `(why, session key)`:
545    /// `shell` or `harness`, and this row's session.
546    fn binds(&self, key: &str, tree: &CastTree) -> Option<(&'static str, String)> {
547        let same = |mine: &Option<String>, theirs: &str| mine.as_deref() == Some(theirs);
548        if same(&tree.shell, &self.shell) && self.session != key {
549            Some(("shell", self.session.clone()))
550        } else if same(&tree.harness, &self.harness)
551            && session_root(&self.session) != session_root(key)
552        {
553            Some(("harness", self.session.clone()))
554        } else {
555            None
556        }
557    }
558}
559
560fn session_root(key: &str) -> &str {
561    key.split(':').next().unwrap_or(key)
562}
563
564fn voice_of(
565    why: &str,
566    session: &str,
567    claimed: &ballot_sig::SessionIdentity,
568) -> ballot_sig::VoiceBind {
569    let (harness, nonce) = session
570        .strip_prefix("nonce:")
571        .map(|nonce| ("-".to_string(), nonce.to_string()))
572        .unwrap_or_else(|| (session.to_string(), "-".to_string()));
573    ballot_sig::VoiceBind {
574        harness,
575        nonce,
576        claimed_harness: claimed.harness.clone(),
577        claimed_nonce: claimed.nonce.clone(),
578        why: why.to_string(),
579    }
580}
581
582fn bound_of(
583    rows: &[TreeRow],
584    key: &str,
585    tree: &CastTree,
586    claimed: &ballot_sig::SessionIdentity,
587) -> Option<ballot_sig::VoiceBind> {
588    let (why, session) = rows.iter().find_map(|row| row.binds(key, tree))?;
589    Some(voice_of(why, &session, claimed))
590}
591
592/// The voice `claimed` folds into on `issue`, when a live row shares its
593/// shell or its harness root. `None` when this process has no tree, or
594/// no row matches.
595pub(crate) fn peek_bound(
596    issue: &str,
597    claimed: &ballot_sig::SessionIdentity,
598) -> Option<ballot_sig::VoiceBind> {
599    let tree = CastTree::here();
600    if tree.shell.is_none() && tree.harness.is_none() {
601        return None;
602    }
603    let key = session_key(&claimed.harness, &claimed.nonce);
604    let text = std::fs::read_to_string(tree_path(issue)).unwrap_or_default();
605    let rows: Vec<TreeRow> = text
606        .lines()
607        .filter_map(TreeRow::parse)
608        .filter(TreeRow::live)
609        .collect();
610    bound_of(&rows, &key, &tree, claimed)
611}
612
613/// The rows of `issue` on this tracker: another tracker's issue of the
614/// same name is another question.
615fn tree_path(issue: &str) -> PathBuf {
616    use sha2::{Digest, Sha256};
617    let tracker = std::env::var_os("VISSUE_ROOT").unwrap_or_default();
618    let tracker = hex::encode(Sha256::digest(tracker.as_encoded_bytes()));
619    crate::runtime_dir()
620        .join("ballot-trees")
621        .join(&tracker[..16])
622        .join(crate::subagent::token(issue))
623}
624
625/// Rewrite the rows of `path` under its lock. `edit` sees the rows still
626/// live and may refuse; what it leaves is written back.
627fn edit_rows<R>(path: &Path, edit: impl FnOnce(&mut Vec<TreeRow>) -> Result<R>) -> Result<R> {
628    let _durable = crate::bookkeep::durable_write();
629    if let Some(dir) = path.parent() {
630        std::fs::create_dir_all(dir)?;
631    }
632    let _lock = crate::CommitLock::acquire(&path.with_extension("lock"));
633    let text = std::fs::read_to_string(path).unwrap_or_default();
634    let mut rows: Vec<TreeRow> = text
635        .lines()
636        .filter_map(TreeRow::parse)
637        .filter(TreeRow::live)
638        .collect();
639    let out = edit(&mut rows)?;
640    let body: String = rows.iter().map(|r| r.line() + "\n").collect();
641    let tmp = path.with_extension(format!("tmp{}", std::process::id()));
642    std::fs::write(&tmp, body)?;
643    std::fs::rename(&tmp, path)?;
644    Ok(out)
645}
646
647/// This process's row on an issue, held until the cast lands or fails.
648#[derive(Debug)]
649struct TreeHold {
650    path: PathBuf,
651    row: TreeRow,
652}
653
654/// Record this process's row. A shared shell or harness does not refuse
655/// the cast. It binds the ballot to the earlier row's session, which is
656/// the voice both ballots count as. The row stores that voice.
657fn hold_tree(
658    issue: &str,
659    claimed: &ballot_sig::SessionIdentity,
660    tree: &CastTree,
661) -> Result<Option<TreeHold>> {
662    if tree.shell.is_none() && tree.harness.is_none() {
663        ballot_sig::clear_voice_bind();
664        return Ok(None);
665    }
666    let key = session_key(&claimed.harness, &claimed.nonce);
667    let me =
668        proc_tree::of(std::process::id()).map_or_else(|| "-".to_string(), |p| proc_tree::token(&p));
669    let path = tree_path(issue);
670    let row = edit_rows(&path, |rows| {
671        let bound = bound_of(rows, &key, tree, claimed);
672        let session = bound
673            .as_ref()
674            .map(|bind| session_key(&bind.harness, &bind.nonce))
675            .filter(|session| !session.is_empty())
676            .unwrap_or_else(|| key.clone());
677        if let Some(bound) = bound {
678            ballot_sig::set_voice_bind(bound);
679        } else {
680            ballot_sig::clear_voice_bind();
681        }
682        let row = TreeRow {
683            shell: tree.shell.clone().unwrap_or_else(|| "-".into()),
684            harness: tree.harness.clone().unwrap_or_else(|| "-".into()),
685            session,
686            state: me,
687        };
688        rows.push(row.clone());
689        Ok(row)
690    })?;
691    Ok(Some(TreeHold { path, row }))
692}
693
694impl TreeHold {
695    /// Mark the row cast, or drop it when the cast did not land.
696    fn settle(&self, kept: bool) {
697        let _ = edit_rows(&self.path, |rows| {
698            if let Some(at) = rows.iter().position(|r| *r == self.row) {
699                if kept {
700                    rows[at].state = CAST_STATE.into();
701                } else {
702                    rows.remove(at);
703                }
704            }
705            Ok(())
706        });
707    }
708}
709
710/// A claim on one voter's ballot for an issue. Dropped without
711/// [`SessionBallot::keep`], the claim is released so a failed cast can be
712/// tried again. Kept, that same voter in this process is refused.
713#[derive(Debug)]
714pub struct SessionBallot {
715    mark: String,
716    kept: bool,
717    tree: Option<TreeHold>,
718}
719
720impl SessionBallot {
721    /// The cast landed. That same voter in this process is refused later.
722    pub fn keep(mut self) {
723        self.kept = true;
724    }
725}
726
727impl Drop for SessionBallot {
728    fn drop(&mut self) {
729        if let Some(tree) = &self.tree {
730            tree.settle(self.kept);
731        }
732        // `keep` drops this value before the note is signed. The voice
733        // bind has to outlive that drop; `sign_ballot` clears it.
734        if !self.kept {
735            ballot_sig::end_cast();
736        }
737        if self.kept {
738            return;
739        }
740        if let Ok(mut held) = CAST.lock() {
741            held.remove(&self.mark);
742        }
743    }
744}
745
746/// The choice `voter`'s row in the drawer holds on `issue`, if any.
747fn held_choice(issue: &str, voter: &str) -> Option<String> {
748    crate::raw_ballot_rows(issue)
749        .ok()?
750        .iter()
751        .find(|row| {
752            row["agent"]
753                .as_str()
754                .is_some_and(|agent| same_voter(agent.trim(), voter))
755        })
756        .map(|row| row["choice"].as_str().unwrap_or("").trim().to_string())
757}
758
759/// Reserve `voter`'s ballot on `issue`. Another persona from this session
760/// is recorded with it. The same voter withdraws before casting again.
761/// This process holds that voter's claim across the gap before the note
762/// is written.
763///
764/// # Errors
765///
766/// This voter already holds a ballot on `issue`, this process already
767/// holds that claim, or it authored a candidate tied to `issue`.
768pub fn claim_session_ballot(issue: &str, voter: &str) -> Result<SessionBallot> {
769    ballot_sig::begin_cast(issue);
770    let claimed = ballot_sig::claimed_identity();
771    let id = ballot_sig::identity();
772    let key = session_key(&id.harness, &id.nonce);
773    let own = notes_on(issue);
774    let family = family_notes(issue);
775    if let Some(why) = session_cast_refusal_on(issue, &key, voter, &own, &family) {
776        ballot_sig::end_cast();
777        bail!("{why}");
778    }
779    if let Some(choice) = held_choice(issue, voter) {
780        ballot_sig::end_cast();
781        bail!(
782            "{voter} already holds a ballot on {issue} for {choice}. Nothing was cast. {}",
783            replace_hint(issue, voter)
784        );
785    }
786    let mark = format!("{}\n{voter}", issue.trim());
787    let mut held = CAST.lock().unwrap_or_else(|e| e.into_inner());
788    if !held.insert(mark.clone()) {
789        ballot_sig::end_cast();
790        bail!(
791            "{voter} already cast a ballot on {issue} in this process. Nothing was cast. {}",
792            replace_hint(issue, voter)
793        );
794    }
795    drop(held);
796    let mut ballot = SessionBallot {
797        mark,
798        kept: false,
799        tree: None,
800    };
801    ballot.tree = hold_tree(issue, &claimed, &CastTree::here())?;
802    Ok(ballot)
803}
804
805/// The refusal for `issue` when a session that filed a candidate also
806/// voted, or `None` when the ballots can be weighed. Persona ballots that
807/// share a parent are not a refusal.
808#[must_use]
809pub fn settle_refusal(issue: &str) -> Option<String> {
810    let ballots = crate::ballot_rows(issue);
811    if ballots.is_empty() {
812        return None;
813    }
814    let notes = crate::issue_record(issue)
815        .map(|v| ballot_sig::notes_of(&v))
816        .unwrap_or_default();
817    let traces = ballot_traces(&ballots, &notes);
818    if traces
819        .iter()
820        .all(|t| session_key(&t.harness, &t.nonce).is_empty())
821    {
822        return None;
823    }
824    let candidates = candidates_in(&candidate_notes(issue));
825    refusal(issue, &traces, &candidates)
826}
827
828/// Blind-design flags for the candidates tied to `issue`.
829#[must_use]
830pub fn blind_lines_for(issue: &str) -> Vec<String> {
831    blind_lines(&candidates_in(&candidate_notes(issue)))
832}
833
834/// Sessions that filed a design candidate tied to `issue`. Their ballots
835/// do not count, and a later cast is refused.
836fn author_sessions(issue: &str) -> std::collections::BTreeSet<String> {
837    candidates_in(&candidate_notes(issue))
838        .into_iter()
839        .map(|c| session_key(&c.harness, &c.nonce))
840        .filter(|key| !key.is_empty())
841        .collect()
842}
843
844/// One counted ballot, and the ballots the tally left out.
845#[derive(Debug, Clone, PartialEq, Eq)]
846pub struct IgnoredBallot {
847    pub voter: String,
848    pub session: String,
849    /// `authored a candidate`.
850    pub why: &'static str,
851    /// Only the seat named that session.
852    pub unverified: bool,
853}
854
855/// The ballots that count, the ones an author session pushed out, and
856/// the lines that say which ballots were folded into one voice.
857struct Tally {
858    counted: Vec<Value>,
859    ignored: Vec<IgnoredBallot>,
860    folds: Vec<String>,
861}
862
863/// One voter's sessions, from the signatures still in force. `newest` is
864/// the index of that voter's latest standing note. Notes are newest first.
865struct VoiceLink {
866    keys: Vec<String>,
867    parents: Vec<String>,
868    /// `(why, voice session)` from a tree-bound note. `why` is `shell` or
869    /// `harness`.
870    links: Vec<(String, String)>,
871    newest: usize,
872}
873
874fn voice_links(sigs: &[(bool, ballot_sig::SigNote)]) -> BTreeMap<String, VoiceLink> {
875    let mut links = BTreeMap::new();
876    for (at, (standing, sig)) in sigs.iter().enumerate() {
877        if !standing {
878            continue;
879        }
880        let harness = sig.harness.clone().unwrap_or_default();
881        let nonce = sig.nonce.clone().unwrap_or_default();
882        let key = session_key(&harness, &nonce);
883        let parent = ballot_sig::parent_session(&harness);
884        let link = links.entry(sig.voter.clone()).or_insert_with(|| VoiceLink {
885            keys: Vec::new(),
886            parents: Vec::new(),
887            links: Vec::new(),
888            newest: at,
889        });
890        if !key.is_empty() && !link.keys.contains(&key) {
891            link.keys.push(key);
892        }
893        if !parent.is_empty() && !link.parents.contains(&parent) {
894            link.parents.push(parent);
895        }
896        if let Some(why) = sig
897            .link
898            .clone()
899            .filter(|why| why == "shell" || why == "harness")
900        {
901            let voice = sig.harness.clone().unwrap_or_default();
902            let pair = (why, voice);
903            if !pair.1.is_empty() && !link.links.contains(&pair) {
904                link.links.push(pair);
905            }
906        }
907    }
908    links
909}
910
911fn find_voice(uf: &mut [usize], mut i: usize) -> usize {
912    while uf[i] != i {
913        uf[i] = uf[uf[i]];
914        i = uf[i];
915    }
916    i
917}
918
919fn unite_voice(uf: &mut [usize], a: usize, b: usize) {
920    let a = find_voice(uf, a);
921    let b = find_voice(uf, b);
922    if a != b {
923        uf[b] = a;
924    }
925}
926
927fn remember_link(at: &mut BTreeMap<String, usize>, uf: &mut [usize], i: usize, name: &str) {
928    if let Some(j) = at.get(name).copied() {
929        unite_voice(uf, i, j);
930    } else {
931        at.insert(name.to_string(), i);
932    }
933}
934
935/// Why these ballots are one voice: one shell, one harness root, one
936/// session id, or one parent.
937fn fold_why(members: &[usize], rows: &[Value], links: &BTreeMap<String, VoiceLink>) -> String {
938    let mut keys = std::collections::BTreeSet::new();
939    let mut parents = std::collections::BTreeSet::new();
940    let mut shells = std::collections::BTreeSet::new();
941    let mut harnesses = std::collections::BTreeSet::new();
942    for &i in members {
943        let voter = rows[i]["agent"].as_str().unwrap_or("").trim();
944        if let Some(link) = links.get(voter) {
945            keys.extend(link.keys.iter().cloned());
946            parents.extend(link.parents.iter().cloned());
947            for (why, voice) in &link.links {
948                if why == "shell" {
949                    shells.insert(voice.clone());
950                } else if why == "harness" {
951                    harnesses.insert(voice.clone());
952                }
953            }
954        }
955    }
956    if !shells.is_empty() {
957        return format!(
958            "same shell {}",
959            shells.into_iter().collect::<Vec<_>>().join(", ")
960        );
961    }
962    if !harnesses.is_empty() {
963        return format!(
964            "same harness {}",
965            harnesses.into_iter().collect::<Vec<_>>().join(", ")
966        );
967    }
968    if keys.len() == 1 {
969        let key = keys.iter().next().map(String::as_str).unwrap_or("");
970        format!("same session {key}")
971    } else if !parents.is_empty() {
972        format!(
973            "same parent {}",
974            parents.into_iter().collect::<Vec<_>>().join(", ")
975        )
976    } else {
977        format!(
978            "same session {}",
979            keys.into_iter().collect::<Vec<_>>().join(", ")
980        )
981    }
982}
983
984/// Keep one row per session. A session is a shared session id or a shared
985/// parent, whichever seat name or key the ballots carry. The kept row is
986/// the latest ballot. The others stay recorded and are named here.
987fn fold_one_voice(
988    rows: Vec<Value>,
989    links: &BTreeMap<String, VoiceLink>,
990) -> (Vec<Value>, Vec<String>) {
991    let n = rows.len();
992    let mut uf: Vec<usize> = (0..n).collect();
993    let mut key_at = BTreeMap::new();
994    let mut parent_at = BTreeMap::new();
995    for (i, row) in rows.iter().enumerate() {
996        let voter = row["agent"].as_str().unwrap_or("").trim();
997        let Some(link) = links.get(voter) else {
998            continue;
999        };
1000        for key in &link.keys {
1001            remember_link(&mut key_at, &mut uf, i, key);
1002        }
1003        for parent in &link.parents {
1004            remember_link(&mut parent_at, &mut uf, i, parent);
1005        }
1006    }
1007    let mut groups: BTreeMap<usize, Vec<usize>> = BTreeMap::new();
1008    for i in 0..n {
1009        groups.entry(find_voice(&mut uf, i)).or_default().push(i);
1010    }
1011    let mut drop = std::collections::BTreeSet::new();
1012    let mut lines = Vec::new();
1013    for members in groups.values() {
1014        if members.len() < 2 {
1015            continue;
1016        }
1017        let keeper = members
1018            .iter()
1019            .copied()
1020            .min_by_key(|&i| {
1021                let voter = rows[i]["agent"].as_str().unwrap_or("").trim();
1022                links
1023                    .get(voter)
1024                    .map(|link| link.newest)
1025                    .unwrap_or(usize::MAX)
1026            })
1027            .unwrap_or(members[0]);
1028        let mut names = Vec::new();
1029        for &i in members {
1030            let voter = rows[i]["agent"].as_str().unwrap_or("").trim().to_string();
1031            if i != keeper {
1032                drop.insert(i);
1033            }
1034            names.push(voter);
1035        }
1036        names.sort();
1037        let counted = rows[keeper]["agent"].as_str().unwrap_or("").trim();
1038        let why = fold_why(members, &rows, links);
1039        lines.push(format!(
1040            "folded into one voice ({why}): {}",
1041            names.join(", ")
1042        ));
1043        lines.push(format!("counted {counted}"));
1044    }
1045    let counted = rows
1046        .into_iter()
1047        .enumerate()
1048        .filter(|(i, _)| !drop.contains(i))
1049        .map(|(_, row)| row)
1050        .collect();
1051    (counted, lines)
1052}
1053
1054/// The ballots that count, and the ones an author session pushed out.
1055/// `notes` are newest first, as the logbook stores them. Ballots that
1056/// share a session id or a parent count as one voice, the latest when
1057/// they disagree. A voter's counted choice is the oldest signature still
1058/// in force, and only while the drawer has a choice that signature set
1059/// names. A voter who withdrew and cast nothing since is dropped. A voter
1060/// with no session binding is kept.
1061#[must_use]
1062pub fn tally_of(
1063    issue: &str,
1064    rows: &[Value],
1065    notes: &[String],
1066    authors: &std::collections::BTreeSet<String>,
1067) -> (Vec<Value>, Vec<IgnoredBallot>) {
1068    let tally = tally_voices(issue, rows, notes, authors);
1069    (tally.counted, tally.ignored)
1070}
1071
1072fn tally_voices(
1073    issue: &str,
1074    rows: &[Value],
1075    notes: &[String],
1076    authors: &std::collections::BTreeSet<String>,
1077) -> Tally {
1078    let cuts = withdrawal_cuts(notes, issue);
1079    let sigs: Vec<(bool, ballot_sig::SigNote)> = notes
1080        .iter()
1081        .enumerate()
1082        .filter_map(|(at, n)| {
1083            let sig = ballot_sig::parse_note(n)?;
1084            let standing = withdrawn_at(&cuts, issue, &sig.voter).is_none_or(|cut| at < cut);
1085            Some((standing, sig))
1086        })
1087        .collect();
1088    // Oldest binding first: session, oldest standing choice, seat-only.
1089    let mut bound: BTreeMap<String, (String, Option<String>, bool)> = BTreeMap::new();
1090    for (standing, sig) in sigs.iter().rev() {
1091        let key = session_key(
1092            sig.harness.as_deref().unwrap_or(""),
1093            sig.nonce.as_deref().unwrap_or(""),
1094        );
1095        if key.is_empty() {
1096            continue;
1097        }
1098        let entry = bound
1099            .entry(sig.voter.clone())
1100            .or_insert_with(|| (key, None, sig.seat_only()));
1101        if entry.1.is_none() && *standing {
1102            entry.1 = Some(sig.choice.clone());
1103        }
1104    }
1105    let mut counted = Vec::new();
1106    let mut ignored = Vec::new();
1107    let mut seen = std::collections::BTreeSet::new();
1108    for row in rows {
1109        let voter = row["agent"].as_str().unwrap_or("").trim();
1110        if voter.is_empty() || !seen.insert(voter.to_string()) {
1111            continue;
1112        }
1113        let any_standing = sigs
1114            .iter()
1115            .any(|(standing, sig)| *standing && sig.voter == voter);
1116        if withdrawn_at(&cuts, issue, voter).is_some() && !any_standing {
1117            continue;
1118        }
1119        if let Some((key, oldest, seat_only)) = bound.get(voter) {
1120            if authors.contains(key) {
1121                ignored.push(IgnoredBallot {
1122                    voter: voter.to_string(),
1123                    session: key.clone(),
1124                    why: "authored a candidate",
1125                    unverified: *seat_only,
1126                });
1127                continue;
1128            }
1129            let Some(choice) = oldest.clone() else {
1130                continue;
1131            };
1132            let drawer = row["choice"].as_str().unwrap_or("").trim();
1133            let signed = sigs
1134                .iter()
1135                .any(|(standing, sig)| *standing && sig.voter == voter && sig.choice == drawer);
1136            if !signed {
1137                continue;
1138            }
1139            let mut row = row.clone();
1140            if let Some(obj) = row.as_object_mut() {
1141                obj.insert("choice".into(), Value::String(choice));
1142            }
1143            counted.push(row);
1144            continue;
1145        }
1146        counted.push(row.clone());
1147    }
1148    let links = voice_links(&sigs);
1149    let (counted, folds) = fold_one_voice(counted, &links);
1150    Tally {
1151        counted,
1152        ignored,
1153        folds,
1154    }
1155}
1156
1157/// [`tally_of`] for the notes and candidate bindings on `issue`.
1158#[must_use]
1159pub fn counted_ballots(issue: &str, rows: &[Value]) -> Vec<Value> {
1160    let notes = crate::issue_record(issue)
1161        .map(|v| ballot_sig::notes_of(&v))
1162        .unwrap_or_default();
1163    tally_of(issue, rows, &notes, &author_sessions(issue)).0
1164}
1165
1166/// The seat of each voter whose first bound ballot only that seat named
1167/// the session of. `notes` are newest first.
1168fn seat_only_voters(notes: &[String]) -> BTreeMap<String, String> {
1169    let mut first = BTreeMap::new();
1170    for sig in notes.iter().rev().filter_map(|n| ballot_sig::parse_note(n)) {
1171        if sig.harness.is_none() {
1172            continue;
1173        }
1174        let seat = sig
1175            .seat_only()
1176            .then(|| sig.harness.clone().unwrap_or_default());
1177        first.entry(sig.voter).or_insert(seat);
1178    }
1179    first
1180        .into_iter()
1181        .filter_map(|(voter, seat)| Some((voter, seat?)))
1182        .collect()
1183}
1184
1185/// The tally a reader sees: the ballots that count, then the ones left
1186/// out because the session had already voted or had filed a candidate.
1187/// A session only the seat named is said to be unverified.
1188///
1189/// # Errors
1190///
1191/// The tracker did not list the ballots.
1192pub fn tally_report(issue: &str) -> Result<String> {
1193    let raw = crate::raw_ballot_rows(issue)?;
1194    let notes = crate::issue_record(issue)
1195        .map(|v| ballot_sig::notes_of(&v))
1196        .unwrap_or_default();
1197    let tally = tally_voices(issue, &raw, &notes, &author_sessions(issue));
1198    let counted = &tally.counted;
1199    let ignored = &tally.ignored;
1200    let seat_only = seat_only_voters(&notes);
1201    let mut unverified = std::collections::BTreeSet::new();
1202    let mut out = String::new();
1203    let n = counted.len();
1204    let word = if n == 1 {
1205        "ballot counts"
1206    } else {
1207        "ballots count"
1208    };
1209    out.push_str(&format!("{issue}: {n} {word}\n"));
1210    for row in counted {
1211        let agent = row["agent"].as_str().unwrap_or("").trim();
1212        let choice = row["choice"].as_str().unwrap_or("").trim();
1213        let raw_choice = raw
1214            .iter()
1215            .find(|r| r["agent"].as_str().map(str::trim) == Some(agent))
1216            .and_then(|r| r["choice"].as_str())
1217            .unwrap_or("");
1218        let mut tags = Vec::new();
1219        if !raw_choice.is_empty() && raw_choice != choice {
1220            tags.push("a later ballot on this session is ignored");
1221        }
1222        if let Some(seat) = seat_only.get(agent) {
1223            unverified.insert(seat.clone());
1224            tags.push("unverified session");
1225        }
1226        if tags.is_empty() {
1227            out.push_str(&format!("  {agent}  {choice}\n"));
1228        } else {
1229            out.push_str(&format!("  {agent}  {choice}  ({})\n", tags.join("; ")));
1230        }
1231    }
1232    if !ignored.is_empty() {
1233        out.push_str("ignored, the session filed a candidate:\n");
1234        for item in ignored {
1235            let unverified_tag = if item.unverified {
1236                unverified.insert(item.session.clone());
1237                ", unverified"
1238            } else {
1239                ""
1240            };
1241            out.push_str(&format!(
1242                "  {}  {}  ({}{unverified_tag})\n",
1243                item.voter, item.session, item.why
1244            ));
1245        }
1246    }
1247    for seat in &unverified {
1248        out.push_str(&format!("{}\n", ballot_sig::unverified_sentence(seat)));
1249    }
1250    out.push_str(&provenance_text(&notes, &raw));
1251    for line in &tally.folds {
1252        out.push_str(line);
1253        out.push('\n');
1254    }
1255    Ok(out)
1256}
1257
1258/// Caster and parent for `voter`. A `ballot-from:` note wins. Otherwise
1259/// the signature's session is the caster and its parent is the parent.
1260fn provenance_of(notes: &[String], voter: &str) -> Option<(String, String)> {
1261    if let Some((caster, persona)) = ballot_sig::parse_caster_persona_voter(voter) {
1262        for note in notes {
1263            if let Some((named, from, parent)) = ballot_sig::parse_from(note) {
1264                if named == persona && from == caster && parent != "-" {
1265                    return Some((from, parent));
1266                }
1267            }
1268        }
1269    }
1270    for note in notes {
1271        if let Some((persona, caster, parent)) = ballot_sig::parse_from(note) {
1272            if persona == voter && parent != "-" {
1273                return Some((caster, parent));
1274            }
1275        }
1276    }
1277    let mut found = None;
1278    for note in notes.iter().rev() {
1279        let Some(sig) = ballot_sig::parse_note(note) else {
1280            continue;
1281        };
1282        if sig.voter != voter {
1283            continue;
1284        }
1285        if found.is_some() {
1286            continue;
1287        }
1288        let harness = sig.harness.clone().unwrap_or_default();
1289        let parent = ballot_sig::parent_session(&harness);
1290        if parent.is_empty() {
1291            continue;
1292        }
1293        let caster = if harness.is_empty() {
1294            parent.clone()
1295        } else {
1296            harness
1297        };
1298        found = Some((caster, parent));
1299    }
1300    found
1301}
1302
1303/// Lines naming each counted ballot's caster and parent, then each parent
1304/// that more than one ballot shares.
1305#[must_use]
1306pub fn provenance_text(notes: &[String], rows: &[Value]) -> String {
1307    let mut lines = Vec::new();
1308    let mut groups: BTreeMap<String, Vec<String>> = BTreeMap::new();
1309    for row in rows {
1310        let voter = row["agent"].as_str().unwrap_or("").trim();
1311        if voter.is_empty() {
1312            continue;
1313        }
1314        let Some((caster, parent)) = provenance_of(notes, voter) else {
1315            continue;
1316        };
1317        let label = ballot_sig::parse_caster_persona_voter(voter)
1318            .map(|(_, persona)| persona)
1319            .unwrap_or_else(|| voter.to_string());
1320        lines.push(format!("  {label} cast by {caster}, parent {parent}"));
1321        groups.entry(parent).or_default().push(label);
1322    }
1323    if lines.is_empty() {
1324        return String::new();
1325    }
1326    let mut out = String::from("provenance:\n");
1327    for line in lines {
1328        out.push_str(&line);
1329        out.push('\n');
1330    }
1331    for (parent, mut names) in groups {
1332        if names.len() < 2 {
1333            continue;
1334        }
1335        names.sort();
1336        names.dedup();
1337        out.push_str(&format!("shared parent {parent}: {}\n", names.join(", ")));
1338    }
1339    out
1340}
1341
1342/// Provenance for every recorded ballot on `issue`, then the ballots
1343/// consensus folded into one voice and why.
1344#[must_use]
1345pub fn panel_provenance(issue: &str) -> String {
1346    let notes = crate::issue_record(issue)
1347        .map(|v| ballot_sig::notes_of(&v))
1348        .unwrap_or_default();
1349    let raw = crate::raw_ballot_rows(issue).unwrap_or_default();
1350    let mut out = provenance_text(&notes, &raw);
1351    let tally = tally_voices(issue, &raw, &notes, &author_sessions(issue));
1352    for line in tally.folds {
1353        out.push_str(&line);
1354        out.push('\n');
1355    }
1356    out
1357}
1358
1359fn reads_path() -> Option<PathBuf> {
1360    let root = std::env::var_os("VISSUE_ROOT").filter(|r| !r.is_empty())?;
1361    Some(PathBuf::from(root).join(READS_FILE))
1362}
1363
1364/// Record that this session read tracker item `issue`. A recall and an
1365/// MCP read both come through here. Best effort.
1366pub fn note_tracker_read(issue: &str) {
1367    let issue = ballot_sig::field(issue);
1368    if issue == "-" {
1369        return;
1370    }
1371    let Some(path) = reads_path() else {
1372        return;
1373    };
1374    let id = ballot_sig::identity();
1375    let line = format!(
1376        "{} {} {} {issue}\n",
1377        crate::now_utc(),
1378        ballot_sig::field(&id.harness),
1379        ballot_sig::field(&id.nonce)
1380    );
1381    if let Some(dir) = path.parent() {
1382        let _ = std::fs::create_dir_all(dir);
1383    }
1384    use std::io::Write;
1385    let _ = std::fs::OpenOptions::new()
1386        .create(true)
1387        .append(true)
1388        .open(path)
1389        .and_then(|mut f| f.write_all(line.as_bytes()));
1390}
1391
1392/// `(session key, issue)` rows of the read log.
1393#[must_use]
1394pub fn read_log(path: &Path) -> Vec<(String, String)> {
1395    let Ok(text) = std::fs::read_to_string(path) else {
1396        return Vec::new();
1397    };
1398    text.lines()
1399        .filter_map(|line| {
1400            let mut parts = line.split_whitespace();
1401            let _time = parts.next()?;
1402            let harness = parts.next()?;
1403            let nonce = parts.next()?;
1404            let issue = parts.next()?;
1405            Some((session_key(harness, nonce), issue.to_string()))
1406        })
1407        .collect()
1408}
1409
1410fn tag_list(v: &Value, key: &str) -> Vec<String> {
1411    match &v[key] {
1412        Value::Array(items) => items
1413            .iter()
1414            .filter_map(|t| t.as_str().map(str::to_string))
1415            .collect(),
1416        Value::String(s) => s.split(',').map(|t| t.trim().to_string()).collect(),
1417        _ => Vec::new(),
1418    }
1419}
1420
1421fn issue_title_and_tags(issue: &str) -> (String, String) {
1422    let Ok(v) = crate::issue_record(issue) else {
1423        return (String::new(), String::new());
1424    };
1425    let title = v["title"].as_str().unwrap_or("").to_string();
1426    let mut tags = tag_list(&v, "tags");
1427    tags.extend(tag_list(&v, "org_tags"));
1428    (title, tags.join(","))
1429}
1430
1431/// A design candidate: a `design:` or `candidate:` title, or the
1432/// `stage-design` tag.
1433#[must_use]
1434pub fn is_design_candidate(title: &str, tags: Option<&str>) -> bool {
1435    let t = title.trim().to_ascii_lowercase();
1436    t.starts_with("design:")
1437        || t.starts_with("candidate:")
1438        || tags
1439            .unwrap_or("")
1440            .split(',')
1441            .any(|tag| tag.trim() == "stage-design")
1442}
1443
1444fn sibling_designs(parent: Option<&str>, self_id: &str) -> Vec<String> {
1445    let Some(parent) = parent.map(str::trim).filter(|s| !s.is_empty()) else {
1446        return Vec::new();
1447    };
1448    children_of(parent)
1449        .into_iter()
1450        .filter(|id| {
1451            if id == self_id {
1452                return false;
1453            }
1454            let (title, tags) = issue_title_and_tags(id);
1455            is_design_candidate(&title, Some(tags.as_str()))
1456        })
1457        .collect()
1458}
1459
1460/// Bind a newly filed design candidate to this session, and flag it when
1461/// the session had already read another candidate. Other titles are left
1462/// alone.
1463///
1464/// # Errors
1465///
1466/// The seat key cannot be used, or the tracker refuses the note.
1467pub fn record_candidate(
1468    id: &str,
1469    title: &str,
1470    parent: Option<&str>,
1471    tags: Option<&str>,
1472) -> Result<String> {
1473    if !is_design_candidate(title, tags) {
1474        return Ok(String::new());
1475    }
1476    let ident = ballot_sig::identity();
1477    let key = session_key(&ident.harness, &ident.nonce);
1478    let others = sibling_designs(parent, id);
1479    let reads = reads_path().map(|p| read_log(&p)).unwrap_or_default();
1480    let saw = reads_that_spoil(&key, &reads, &others);
1481    let seat = crate::seat_name();
1482    let note = ballot_sig::sign_candidate(id, &seat, &ident, &saw)?;
1483    crate::run_captured("vissue", &["note", id, &note])?;
1484    let mut out = format!("candidate {id} bound to session {key}\n");
1485    if !saw.is_empty() {
1486        let line = blind_sentence(id, &key, &saw);
1487        let _ = crate::run_captured(
1488            "vissue",
1489            &["note", id, &format!("design-seen: {id} {}", saw.join(" "))],
1490        );
1491        out.push_str(&format!("blind design: {line}\n"));
1492    }
1493    Ok(out)
1494}
1495
1496/// Headless argv for `harness` on `prompt`.
1497#[must_use]
1498pub fn harness_argv(harness: &str, prompt: &str) -> Vec<String> {
1499    let trimmed = harness.trim();
1500    if trimmed.is_empty() || trimmed == "cursor" || trimmed == "cursor-agent" {
1501        return vec!["cursor-agent".into(), "-p".into(), prompt.into()];
1502    }
1503    if let Some(template) = crate::panel_runner::template_for_runner(trimmed, false, false) {
1504        let vars = crate::tools::Vars::default()
1505            .with("prompt", prompt)
1506            .with("cwd", ".")
1507            .with("persona", "member");
1508        return crate::tools::fill(&template, &vars);
1509    }
1510    vec![trimmed.into(), "-p".into(), prompt.into()]
1511}
1512
1513/// What one spawned member runs. `LJOS_MEMBER_BIN` is the test stand-in.
1514#[must_use]
1515pub fn member_argv(harness: &str, prompt: &str) -> Vec<String> {
1516    crate::panel_runner::run_argv_for(harness, prompt, "member")
1517}
1518
1519/// The only text a designer is given: this issue, not the other candidates.
1520#[must_use]
1521pub fn designer_prompt(issue: &str, n: usize, of: usize, brief: &str) -> String {
1522    format!(
1523        "You are designer {n} of {of} on {issue}, in your own harness session.\n\
1524         The brief below is the only description you are given. Do not read another design, \
1525         `vissue children`, `vissue vote`, or `ljos consensus`. Do not vote.\n\
1526         File one candidate and stop:\n\
1527         `ljos file \"design: TITLE\" --parent {issue} -t task --body \"YOUR DESIGN\"`\n\
1528         \n\
1529         == brief\n\
1530         {brief}\n"
1531    )
1532}
1533
1534fn issue_brief(issue: &str) -> String {
1535    match crate::issue_record(issue) {
1536        Ok(v) => {
1537            let title = v["title"].as_str().unwrap_or("");
1538            let body = v["body"].as_str().unwrap_or("");
1539            let text = format!("{title}\n{body}");
1540            let text = text.trim();
1541            if text.is_empty() {
1542                format!("Issue {issue}.")
1543            } else {
1544                text.to_string()
1545            }
1546        }
1547        Err(_) => format!("Issue {issue}."),
1548    }
1549}
1550
1551fn persona_names(issue: &str) -> Vec<String> {
1552    if let Ok(raw) = std::env::var("LJOS_PANEL_PERSONAS") {
1553        let names: Vec<String> = raw
1554            .split(',')
1555            .map(str::trim)
1556            .filter(|s| !s.is_empty())
1557            .map(str::to_string)
1558            .collect();
1559        if !names.is_empty() {
1560            return names;
1561        }
1562    }
1563    if crate::bound_playbook(issue).is_none() {
1564        return Vec::new();
1565    }
1566    let all = crate::personas_from_pack().unwrap_or_default();
1567    crate::panel_personas(issue, &all)
1568        .into_iter()
1569        .map(|p| p.name)
1570        .collect()
1571}
1572
1573fn fresh_session(issue: &str, role: &str) -> String {
1574    use std::io::Read;
1575    let mut buf = [0u8; 4];
1576    let _ = std::fs::File::open("/dev/urandom").and_then(|mut f| f.read_exact(&mut buf));
1577    format!(
1578        "panel-{}-{}-{}",
1579        ballot_sig::field(issue),
1580        ballot_sig::field(role),
1581        hex::encode(buf)
1582    )
1583}
1584
1585#[derive(Clone)]
1586struct Job {
1587    role: String,
1588    session: String,
1589    prompt: String,
1590    argv: Vec<String>,
1591}
1592
1593fn plan(issue: &str, harness: &str, designers: usize, personas: &[String]) -> Vec<Job> {
1594    let brief = issue_brief(issue);
1595    let mut jobs = Vec::new();
1596    for n in 1..=designers {
1597        let role = format!("designer-{n}");
1598        let prompt = designer_prompt(issue, n, designers, &brief);
1599        jobs.push(Job {
1600            session: fresh_session(issue, &role),
1601            argv: crate::panel_runner::run_argv_for(harness, &prompt, &role),
1602            role,
1603            prompt,
1604        });
1605    }
1606    for name in personas {
1607        let prompt = match crate::brief(name, issue) {
1608            Ok(text) => crate::decision_member_task(&text, name, issue),
1609            Err(_) => format!(
1610                "You are {name}, in your own harness session. Cast exactly one ballot on {issue} \
1611                 and stop. Do not read `vissue vote {issue}` or another persona's ballot first.\n\
1612                 `ljos vote {issue} --for OPTION --expect OPTION --as {name} --confidence 0.7 --used none`.\n"
1613            ),
1614        };
1615        jobs.push(Job {
1616            session: fresh_session(issue, name),
1617            argv: crate::panel_runner::run_argv_for(harness, &prompt, name),
1618            role: name.clone(),
1619            prompt,
1620        });
1621    }
1622    jobs
1623}
1624
1625fn format_plan(issue: &str, harness: &str, jobs: &[Job], dry_run: bool) -> String {
1626    let mut out = format!(
1627        "panel run {issue}: {} harness sessions ({harness})\n",
1628        jobs.len()
1629    );
1630    for job in jobs {
1631        let kind = if job.role.starts_with("designer-") {
1632            "designer, brief only"
1633        } else {
1634            "persona"
1635        };
1636        out.push_str(&format!("  {} session {} {kind}\n", job.role, job.session));
1637        if dry_run {
1638            out.push_str(&format!(
1639                "  argv: {}\n",
1640                job.argv[..job.argv.len().min(2)].join(" ")
1641            ));
1642            out.push_str(&job.prompt);
1643            if !job.prompt.ends_with('\n') {
1644                out.push('\n');
1645            }
1646        }
1647    }
1648    out
1649}
1650
1651/// One session run to its end. A session that ran and failed is an error
1652/// like one that never started: neither cast a ballot.
1653fn run_job(job: &Job) -> Result<String> {
1654    if job.argv.is_empty() {
1655        anyhow::bail!("{} did not start: no argv", job.role);
1656    }
1657    let mut cmd = Command::new(&job.argv[0]);
1658    cmd.args(&job.argv[1..])
1659        .env("LJOS_HARNESS_SESSION", &job.session)
1660        .env("LJOS_SESSION_ID", &job.session)
1661        .env("SEAT_SESSION_ID", &job.session)
1662        .env("LJOS_PANEL_CHILD", "1")
1663        .env("LJOS_SEAT", &job.role)
1664        .env("VISSUE_AGENT", &job.role)
1665        .env_remove("LJOS_PROCESS_NONCE")
1666        .stdin(Stdio::null())
1667        .stdout(Stdio::piped())
1668        .stderr(Stdio::piped());
1669    match cmd.output() {
1670        Ok(out) if out.status.success() => {
1671            Ok(format!("{} session {} exited 0\n", job.role, job.session))
1672        }
1673        Ok(out) => anyhow::bail!(
1674            "{} session {} exited {}",
1675            job.role,
1676            job.session,
1677            out.status.code().unwrap_or(1)
1678        ),
1679        Err(e) => anyhow::bail!(
1680            "{} session {} did not start ({}): {e}",
1681            job.role,
1682            job.session,
1683            job.argv[0]
1684        ),
1685    }
1686}
1687
1688/// Spawn each designer and each persona as its own harness session.
1689/// Designers receive only the brief. `dry_run` prints the plan and starts
1690/// nothing. Picks from the runner CLIs installed on `PATH`, or uses a
1691/// configured command template (`--harness`, `LJOS_PANEL_HARNESS`,
1692/// `LJOS_PANEL_COMMAND`, `harnesses.toml`, or `ljos.toml`).
1693///
1694/// # Errors
1695///
1696/// There is nobody to spawn, or no runner CLI is available.
1697pub fn panel_run(
1698    issue: &str,
1699    harness: Option<&str>,
1700    designers: usize,
1701    dry_run: bool,
1702) -> Result<String> {
1703    if std::env::var_os("LJOS_PANEL_CHILD").is_some() {
1704        return Ok(format!(
1705            "panel run: {issue} is already inside a panel member; nothing spawned\n"
1706        ));
1707    }
1708    let personas = persona_names(issue);
1709    if designers == 0 && personas.is_empty() {
1710        anyhow::bail!(
1711            "panel run: {issue} has no designers and no personas; pass --designers N or seat a persona"
1712        );
1713    }
1714    let harness =
1715        crate::panel_runner::resolve_panel_run_harness_with(harness, dry_run, &crate::on_path)?;
1716    let jobs = plan(issue, &harness, designers, &personas);
1717    let mut out = format_plan(issue, &harness, &jobs, dry_run);
1718    if dry_run {
1719        out.push_str("dry run: nothing spawned\n");
1720        return Ok(out);
1721    }
1722    let at_once = crate::panel_concurrency().max(1);
1723    let mut failed = 0;
1724    for chunk in jobs.chunks(at_once) {
1725        let mut handles = Vec::new();
1726        for job in chunk {
1727            let job = job.clone();
1728            handles.push(std::thread::spawn(move || run_job(&job)));
1729        }
1730        for handle in handles {
1731            match handle.join() {
1732                Ok(Ok(line)) => out.push_str(&line),
1733                Ok(Err(err)) => {
1734                    failed += 1;
1735                    out.push_str(&format!("{err:#}\n"));
1736                }
1737                Err(_) => {
1738                    failed += 1;
1739                    out.push_str("a panel member thread stopped\n");
1740                }
1741            }
1742        }
1743    }
1744    let ran = jobs.len() - failed;
1745    if failed > 0 {
1746        anyhow::bail!(
1747            "{out}panel run {issue}: opened {ran} of {} sessions",
1748            jobs.len()
1749        );
1750    }
1751    out.push_str(&format!(
1752        "panel run {issue}: opened {ran} of {} sessions\n",
1753        jobs.len()
1754    ));
1755    Ok(out)
1756}
1757
1758/// The issue body a designer would be handed, for a test that checks the
1759/// prompt carries no other candidate.
1760#[must_use]
1761pub fn designer_prompt_for(issue: &str, n: usize, of: usize) -> String {
1762    designer_prompt(issue, n, of, &issue_brief(issue))
1763}
1764
1765#[cfg(test)]
1766mod tests {
1767    use super::*;
1768
1769    #[test]
1770    fn a_shell_casts_as_one_session_and_a_harness_as_one_root() {
1771        let row = TreeRow {
1772            shell: "b:10:1".into(),
1773            harness: "b:20:2".into(),
1774            session: "sess-root:agent-a".into(),
1775            state: CAST_STATE.into(),
1776        };
1777        assert_eq!(TreeRow::parse(&row.line()), Some(row.clone()));
1778        let tree = |shell: &str, harness: &str| CastTree {
1779            shell: Some(shell.into()),
1780            harness: Some(harness.into()),
1781        };
1782        assert_eq!(
1783            row.binds("sess-root:agent-b", &tree("b:10:1", "b:30:3")),
1784            Some(("shell", "sess-root:agent-a".to_string())),
1785            "the same shell binds the later session to the earlier voice"
1786        );
1787        assert_eq!(
1788            row.binds("sess-root:agent-a", &tree("b:10:1", "b:20:2")),
1789            None,
1790            "the same session is the cast gate's to judge"
1791        );
1792        assert_eq!(
1793            row.binds("sess-root:agent-b", &tree("b:11:1", "b:20:2")),
1794            None,
1795            "a subagent of the same root counts with its parent"
1796        );
1797        assert_eq!(
1798            row.binds("sess-other", &tree("b:11:1", "b:20:2")),
1799            Some(("harness", "sess-root:agent-a".to_string())),
1800            "another root session on the same harness is the earlier voice"
1801        );
1802        assert_eq!(row.binds("sess-other", &CastTree::default()), None);
1803        assert!(!row.live(), "a row naming no running process is dropped");
1804    }
1805
1806    #[test]
1807    fn a_linked_ballot_folds_into_the_earlier_voice() {
1808        let k = ed25519_dalek::SigningKey::from_bytes(&[8; 32]);
1809        let first = ballot_sig::sign_bound(&k, "demo-tree", "alice", "ship", "sess-a", "-");
1810        let second = ballot_sig::sign_linked(
1811            &k,
1812            "demo-tree",
1813            "bea",
1814            "hold",
1815            ballot_sig::LinkedFields {
1816                harness: "sess-a",
1817                nonce: "-",
1818                claimed_harness: "sess-b",
1819                claimed_nonce: "-",
1820                link: "shell",
1821            },
1822        );
1823        let rows = [
1824            serde_json::json!({"agent": "alice", "choice": "ship"}),
1825            serde_json::json!({"agent": "bea", "choice": "hold"}),
1826        ];
1827        let tally = tally_voices(
1828            "demo-tree",
1829            &rows,
1830            &[second, first],
1831            &std::collections::BTreeSet::new(),
1832        );
1833        assert_eq!(tally.counted.len(), 1, "{:?}", tally.counted);
1834        assert!(
1835            tally
1836                .folds
1837                .iter()
1838                .any(|line| line.contains("folded into one voice (same shell sess-a)")),
1839            "{:?}",
1840            tally.folds
1841        );
1842        let apart = [
1843            ballot_sig::sign_bound(&k, "demo-tree", "carol", "ship", "sess-c", "-"),
1844            ballot_sig::sign_bound(&k, "demo-tree", "dora", "ship", "sess-d", "-"),
1845        ];
1846        let rows = [
1847            serde_json::json!({"agent": "carol", "choice": "ship"}),
1848            serde_json::json!({"agent": "dora", "choice": "ship"}),
1849        ];
1850        let (counted, _) = tally_of(
1851            "demo-tree",
1852            &rows,
1853            &apart,
1854            &std::collections::BTreeSet::new(),
1855        );
1856        assert_eq!(counted.len(), 2, "{counted:?}");
1857    }
1858
1859    fn seat(voter: &str, harness: &str) -> BallotTrace {
1860        BallotTrace {
1861            voter: voter.into(),
1862            harness: harness.into(),
1863            nonce: format!("n-{voter}"),
1864        }
1865    }
1866
1867    #[test]
1868    fn one_connection_cannot_cast_again_by_naming_another_seat() {
1869        let key = ed25519_dalek::SigningKey::from_bytes(&[3; 32]);
1870        let ballot = ballot_sig::sign_bound(
1871            &key,
1872            "demo-ind1",
1873            "alice",
1874            "ship",
1875            "sess-shared-0001",
1876            "n-alice",
1877        );
1878        let own = [ballot];
1879        let family = [(
1880            "demo-vote".to_string(),
1881            "candidate-sig: demo-d001 alice sess-author-0001 n-author - ab cd".to_string(),
1882        )];
1883        assert!(
1884            session_cast_refusal("demo-ind1", "sess-shared-0001", "bea", &own, &family).is_none(),
1885            "another persona from this session is recorded"
1886        );
1887        let recast =
1888            session_cast_refusal("demo-ind1", "sess-shared-0001", "alice", &own, &family).unwrap();
1889        assert!(
1890            recast.contains("already cast") && recast.contains("Nothing was cast"),
1891            "{recast}"
1892        );
1893        let author =
1894            session_cast_refusal("demo-vote", "sess-author-0001", "alice", &[], &family).unwrap();
1895        assert!(author.contains("authored candidate demo-d001"), "{author}");
1896        let own_candidate =
1897            ["candidate-sig: demo-d001 alice sess-author-0001 n-author - ab cd".to_string()];
1898        let on_own = session_cast_refusal(
1899            "demo-vote",
1900            "sess-author-0001",
1901            "alice",
1902            &own_candidate,
1903            &[],
1904        )
1905        .unwrap();
1906        assert!(on_own.contains("authored candidate demo-d001"), "{on_own}");
1907        assert!(session_cast_refusal("demo-ind1", "sess-bob-0001", "bob", &own, &family).is_none());
1908        let _g = crate::test_env_lock();
1909        let dir = tempfile::tempdir().unwrap();
1910        std::env::set_var("VISSUE_ROOT", dir.path());
1911        std::env::set_var("LJOS_HARNESS_SESSION", "sess-live-0001");
1912        std::env::set_var("LJOS_PROCESS_NONCE", "nonce-live-0001");
1913        let first = claim_session_ballot("probe-seat-1", "saddle").unwrap();
1914        let second = claim_session_ballot("probe-seat-1", "other");
1915        assert!(second.is_ok(), "{second:?}");
1916        let again = claim_session_ballot("probe-seat-1", "saddle").unwrap_err();
1917        assert!(again.to_string().contains("already cast"), "{again:#}");
1918        first.keep();
1919        std::env::remove_var("VISSUE_ROOT");
1920        std::env::remove_var("LJOS_HARNESS_SESSION");
1921        std::env::remove_var("LJOS_PROCESS_NONCE");
1922    }
1923
1924    #[test]
1925    fn a_signature_for_this_issue_on_its_family_still_counts() {
1926        let key = ed25519_dalek::SigningKey::from_bytes(&[4; 32]);
1927        let on_b = ballot_sig::sign_bound(&key, "demo-b", "saddle", "ship", "sess-one", "-");
1928        let stray = [("parent-b".to_string(), on_b.clone())];
1929        assert!(
1930            session_cast_refusal("demo-b", "sess-one", "bea", &[], &stray).is_none(),
1931            "another persona is recorded beside saddle"
1932        );
1933        let withdrawn = ballot_sig::withdrawn_line("saddle", "sess-one", "-");
1934        let own = [withdrawn, on_b.clone()];
1935        assert!(
1936            session_cast_refusal("demo-b", "sess-one", "saddle", &own, &stray).is_none(),
1937            "the issue's own copy in the family is read once, with its withdrawal"
1938        );
1939    }
1940
1941    #[test]
1942    fn a_parent_cut_for_a_child_issue_is_read_on_the_parent_index() {
1943        let key = ed25519_dalek::SigningKey::from_bytes(&[6; 32]);
1944        let issue = "child-a";
1945        let parent = "parent-1";
1946        let own = [ballot_sig::sign_bound(
1947            &key, issue, "bea", "ship", "sess-bea", "-",
1948        )];
1949        let sig = ballot_sig::sign_bound(&key, issue, "saddle", "ship", "sess-saddle", "-");
1950        let cut = ballot_sig::withdrawn_line_on("saddle", "sess-saddle", "-", Some(issue));
1951        let family = [(parent.to_string(), cut), (parent.to_string(), sig)];
1952        assert_eq!(
1953            session_cast_refusal(issue, "sess-saddle", "saddle", &own, &family),
1954            None,
1955            "a parent cut for child-a must not be indexed against child-a's own notes"
1956        );
1957    }
1958
1959    #[test]
1960    fn a_choice_with_a_space_is_still_this_sessions_one_ballot() {
1961        // The panel eval's unscored case on 7e21d3c: session spaced-1 cast
1962        // `beside binary` as seat-a, then `xdg home`, and both landed.
1963        let k = ed25519_dalek::SigningKey::from_bytes(&[9; 32]);
1964        let first =
1965            ballot_sig::sign_bound(&k, "panel-spcd", "seat-a", "beside binary", "spaced-1", "-");
1966        let again = session_cast_refusal(
1967            "panel-spcd",
1968            "spaced-1",
1969            "seat-a",
1970            std::slice::from_ref(&first),
1971            &[],
1972        )
1973        .expect("a second cast from spaced-1 was let through");
1974        assert!(
1975            again.contains("already cast") && again.contains("Nothing was cast"),
1976            "{again}"
1977        );
1978        assert!(session_cast_refusal(
1979            "panel-spcd",
1980            "other-sess",
1981            "seat-b",
1982            std::slice::from_ref(&first),
1983            &[],
1984        )
1985        .is_none());
1986        // The drawer already has both, as the eval left it. The oldest
1987        // signature is the choice that counts.
1988        let later = ballot_sig::sign_bound(&k, "panel-spcd", "seat-a", "xdg home", "spaced-1", "-");
1989        let rows = [serde_json::json!({"agent": "seat-a", "choice": "xdg home"})];
1990        let (counted, _) = tally_of(
1991            "panel-spcd",
1992            &rows,
1993            &[later, first],
1994            &std::collections::BTreeSet::new(),
1995        );
1996        assert_eq!(counted.len(), 1, "{counted:?}");
1997        assert_eq!(counted[0]["choice"], "beside binary");
1998        // A note that does not parse, and does not verify, does not bind.
1999        let odd = "ballot-sig: seat-a beside binary ab cd spaced-1 -".to_string();
2000        assert!(session_cast_refusal("panel-spcd", "spaced-1", "seat-a", &[odd], &[]).is_none());
2001    }
2002
2003    #[test]
2004    fn five_personas_one_sand_session_count_as_the_first_ballot() {
2005        let session = "sand-subagent-fcdb9c57-9988-7d91-4311-9fc5e42f7529";
2006        let personas = ["academic", "transit", "safety", "cat-owner", "finance"];
2007        let mut notes = Vec::new();
2008        // Newest first, as the logbook stores them: the 11:46 recast, then
2009        // the 10:35 ballots, academic's first cast last.
2010        for persona in personas.iter().rev() {
2011            notes.push(format!(
2012                "ballot-sig: {persona} bus ab cd {session} n-{persona}-2"
2013            ));
2014        }
2015        for persona in personas.iter().rev() {
2016            notes.push(format!(
2017                "ballot-sig: {persona} rail ab cd {session} n-{persona}-1"
2018            ));
2019        }
2020        let rows: Vec<Value> = personas
2021            .iter()
2022            .map(|persona| serde_json::json!({"agent": persona, "choice": "bus"}))
2023            .collect();
2024        let tally = tally_voices(
2025            "travel-rx1d",
2026            &rows,
2027            &notes,
2028            &std::collections::BTreeSet::new(),
2029        );
2030        assert_eq!(tally.counted.len(), 1, "{:?}", tally.counted);
2031        assert_eq!(tally.counted[0]["agent"], "finance");
2032        assert_eq!(tally.counted[0]["choice"], "rail");
2033        assert!(tally.ignored.is_empty(), "{:?}", tally.ignored);
2034        let said = provenance_text(&notes, &rows);
2035        assert!(
2036            said.contains(&format!("shared parent {session}:")),
2037            "{said}"
2038        );
2039        let folded = tally.folds.join("\n");
2040        assert!(
2041            folded.contains(&format!("same session {session}"))
2042                && personas.iter().all(|persona| folded.contains(persona)),
2043            "{folded}"
2044        );
2045        let mut authors = std::collections::BTreeSet::new();
2046        authors.insert(session.to_string());
2047        let (none, dropped) = tally_of("travel-rx1d", &rows, &notes, &authors);
2048        assert!(none.is_empty(), "{none:?}");
2049        assert_eq!(dropped.len(), 5, "{dropped:?}");
2050        assert!(dropped
2051            .iter()
2052            .all(|item| item.why == "authored a candidate"));
2053    }
2054
2055    fn note_of(voter: &str, choice: &str, harness: &str) -> String {
2056        format!("ballot-sig: {voter} {choice} ab cd {harness} -")
2057    }
2058
2059    #[test]
2060    fn one_session_that_switches_seats_counts_the_latest_ballot() {
2061        let session = "sess-switch-0001";
2062        let notes = vec![
2063            note_of("bob", "hold", session),
2064            note_of("alice", "ship", session),
2065        ];
2066        let rows = [
2067            serde_json::json!({"agent": "alice", "choice": "ship"}),
2068            serde_json::json!({"agent": "bob", "choice": "hold"}),
2069        ];
2070        let tally = tally_voices(
2071            "demo-switch",
2072            &rows,
2073            &notes,
2074            &std::collections::BTreeSet::new(),
2075        );
2076        assert_eq!(tally.counted.len(), 1, "{:?}", tally.counted);
2077        assert_eq!(tally.counted[0]["agent"], "bob");
2078        assert_eq!(tally.counted[0]["choice"], "hold");
2079        let folded = tally.folds.join("\n");
2080        assert!(
2081            folded.contains(&format!("same session {session}"))
2082                && folded.contains("alice")
2083                && folded.contains("bob")
2084                && folded.contains("counted bob"),
2085            "{folded}"
2086        );
2087    }
2088
2089    #[test]
2090    fn three_subagents_of_one_parent_are_one_voice() {
2091        let parent = "sess-parent-0001";
2092        let notes = vec![
2093            note_of("cee", "ship", &format!("{parent}:agent-c")),
2094            note_of("bee", "hold", &format!("{parent}:agent-b")),
2095            note_of("aye", "ship", &format!("{parent}:agent-a")),
2096        ];
2097        let rows = [
2098            serde_json::json!({"agent": "aye", "choice": "ship"}),
2099            serde_json::json!({"agent": "bee", "choice": "hold"}),
2100            serde_json::json!({"agent": "cee", "choice": "ship"}),
2101        ];
2102        let tally = tally_voices(
2103            "demo-three",
2104            &rows,
2105            &notes,
2106            &std::collections::BTreeSet::new(),
2107        );
2108        assert_eq!(tally.counted.len(), 1, "{:?}", tally.counted);
2109        assert_eq!(tally.counted[0]["agent"], "cee");
2110        assert_eq!(tally.counted[0]["choice"], "ship");
2111        let folded = tally.folds.join("\n");
2112        assert!(
2113            folded.contains(&format!("same parent {parent}"))
2114                && folded.contains("aye")
2115                && folded.contains("bee")
2116                && folded.contains("cee"),
2117            "{folded}"
2118        );
2119    }
2120
2121    #[test]
2122    fn two_sessions_count_as_two_voices() {
2123        let notes = vec![
2124            note_of("bob", "hold", "sess-bob-0001"),
2125            note_of("alice", "ship", "sess-alice-0001"),
2126        ];
2127        let rows = [
2128            serde_json::json!({"agent": "alice", "choice": "ship"}),
2129            serde_json::json!({"agent": "bob", "choice": "hold"}),
2130        ];
2131        let tally = tally_voices(
2132            "demo-two",
2133            &rows,
2134            &notes,
2135            &std::collections::BTreeSet::new(),
2136        );
2137        assert_eq!(tally.counted.len(), 2, "{:?}", tally.counted);
2138        assert!(tally.folds.is_empty(), "{:?}", tally.folds);
2139    }
2140
2141    #[test]
2142    fn one_session_of_n_seats_is_weighed() {
2143        let ballots = vec![
2144            seat("carol", "sess-shared-0001"),
2145            seat("alice", "sess-shared-0001"),
2146            seat("bob", "sess-shared-0001"),
2147        ];
2148        assert_eq!(refusal("demo-ind1", &ballots, &[]), None);
2149    }
2150
2151    #[test]
2152    fn n_sessions_settle() {
2153        let ballots = vec![
2154            seat("alice", "sess-alice-0001"),
2155            seat("bob", "sess-bob-0001"),
2156            seat("carol", "sess-carol-0001"),
2157        ];
2158        assert_eq!(refusal("demo-ind2", &ballots, &[]), None);
2159    }
2160
2161    #[test]
2162    fn an_author_who_votes_is_refused_even_when_the_other_ballots_are_not_theirs() {
2163        let ballots = vec![
2164            seat("alice", "sess-alice-0001"),
2165            seat("bob", "sess-bob-0001"),
2166        ];
2167        let candidates = vec![CandidateTrace {
2168            id: "demo-d001".into(),
2169            harness: "sess-alice-0001".into(),
2170            nonce: "n-alice".into(),
2171            saw: vec![],
2172        }];
2173        let msg = refusal("demo-vote", &ballots, &candidates).unwrap();
2174        assert_eq!(
2175            msg,
2176            "refuses to settle demo-vote: session sess-alice-0001 authored candidate demo-d001 \
2177             and voted on demo-vote"
2178                .to_string()
2179        );
2180    }
2181
2182    #[test]
2183    fn a_session_that_read_another_candidate_is_flagged() {
2184        let reads = vec![
2185            ("sess-1".into(), "demo-b".into()),
2186            ("sess-2".into(), "demo-a".into()),
2187        ];
2188        let saw = reads_that_spoil("sess-1", &reads, &["demo-a".into(), "demo-b".into()]);
2189        assert_eq!(saw, vec!["demo-b".to_string()]);
2190        let line = blind_sentence("demo-a", "sess-1", &saw);
2191        assert_eq!(
2192            line,
2193            "design demo-a is not blind: session sess-1 read demo-b before filing it"
2194        );
2195        let quiet = reads_that_spoil("sess-2", &reads, &["demo-b".into()]);
2196        assert!(quiet.is_empty(), "{quiet:?}");
2197    }
2198
2199    #[test]
2200    fn a_designer_prompt_is_only_the_brief() {
2201        let prompt = designer_prompt("demo-1", 1, 3, "Ship the cache.\nKeep the ledger.");
2202        assert!(prompt.contains("== brief"), "{prompt}");
2203        assert!(prompt.contains("Ship the cache."), "{prompt}");
2204        assert!(prompt.contains("Do not read another design"), "{prompt}");
2205        assert!(prompt.contains("Do not vote"), "{prompt}");
2206        assert!(!prompt.contains("OTHER-CANDIDATE-BODY"), "{prompt}");
2207        let argv = harness_argv("cursor-agent", "hello");
2208        assert_eq!(argv, vec!["cursor-agent", "-p", "hello"]);
2209        let claude = harness_argv("claude", "hello");
2210        assert_eq!(&claude[..3], ["claude", "-p", "hello"]);
2211    }
2212
2213    #[test]
2214    fn unbound_ballots_are_left_alone() {
2215        let ballots = vec![
2216            BallotTrace {
2217                voter: "alice".into(),
2218                harness: String::new(),
2219                nonce: String::new(),
2220            },
2221            BallotTrace {
2222                voter: "bob".into(),
2223                harness: "-".into(),
2224                nonce: "-".into(),
2225            },
2226        ];
2227        assert_eq!(refusal("old-tracker", &ballots, &[]), None);
2228    }
2229
2230    #[test]
2231    fn the_same_nonce_without_a_harness_is_one_session() {
2232        let ballots = vec![
2233            BallotTrace {
2234                voter: "alice".into(),
2235                harness: "-".into(),
2236                nonce: "same-nonce".into(),
2237            },
2238            BallotTrace {
2239                voter: "bob".into(),
2240                harness: "-".into(),
2241                nonce: "same-nonce".into(),
2242            },
2243        ];
2244        assert_eq!(session_key("-", "same-nonce"), "nonce:same-nonce");
2245        assert_eq!(refusal("demo-n", &ballots, &[]), None);
2246    }
2247
2248    #[test]
2249    fn a_seat_only_session_keeps_the_seat_as_its_key() {
2250        assert_eq!(
2251            session_key("ljosbot", ballot_sig::SEAT_NONCE),
2252            session_key("ljosbot", "-")
2253        );
2254        let key = ed25519_dalek::SigningKey::from_bytes(&[9; 32]);
2255        // A ballot the seat cast before the marker still holds its session.
2256        let old = ballot_sig::sign_bound(&key, "demo-s", "alice", "ship", "ljosbot", "-");
2257        assert!(
2258            session_cast_refusal("demo-s", "ljosbot", "bea", std::slice::from_ref(&old), &[])
2259                .is_none()
2260        );
2261        let again = session_cast_refusal("demo-s", "ljosbot", "alice", &[old], &[]).unwrap();
2262        assert!(again.contains("already cast"), "{again}");
2263        assert!(!again.contains("unverified"), "{again}");
2264        let marked = ballot_sig::sign_bound(
2265            &key,
2266            "demo-s",
2267            "alice",
2268            "ship",
2269            "ljosbot",
2270            ballot_sig::SEAT_NONCE,
2271        );
2272        assert!(session_cast_refusal(
2273            "demo-s",
2274            "ljosbot",
2275            "bea",
2276            std::slice::from_ref(&marked),
2277            &[],
2278        )
2279        .is_none());
2280        let again = session_cast_refusal("demo-s", "ljosbot", "alice", &[marked], &[]).unwrap();
2281        assert!(
2282            again.contains("already cast")
2283                && again.contains(&ballot_sig::unverified_sentence("ljosbot")),
2284            "{again}"
2285        );
2286    }
2287
2288    #[test]
2289    fn a_siblings_signature_does_not_block_this_issue() {
2290        let key = ed25519_dalek::SigningKey::from_bytes(&[7; 32]);
2291        let sibling =
2292            ballot_sig::sign_bound(&key, "rgpot-9ot3", "saddle", "ship", "sess-saddle", "-");
2293        assert!(session_cast_refusal(
2294            "eOn-1stn",
2295            "sess-saddle",
2296            "saddle",
2297            &[],
2298            &[("rgpot-9ot3".to_string(), sibling.clone())],
2299        )
2300        .is_none());
2301        assert!(
2302            session_cast_refusal(
2303                "eOn-1stn",
2304                "sess-saddle",
2305                "saddle",
2306                std::slice::from_ref(&sibling),
2307                &[],
2308            )
2309            .is_none(),
2310            "a signature for another issue does not verify here"
2311        );
2312        let here = ballot_sig::sign_bound(&key, "eOn-1stn", "saddle", "ship", "sess-saddle", "-");
2313        let blocked = session_cast_refusal(
2314            "eOn-1stn",
2315            "sess-saddle",
2316            "saddle",
2317            std::slice::from_ref(&here),
2318            &[],
2319        )
2320        .unwrap();
2321        assert!(
2322            blocked.contains("already cast") && blocked.contains("Nothing was cast"),
2323            "{blocked}"
2324        );
2325    }
2326
2327    #[test]
2328    fn a_withdrawn_ballot_drops_out_of_the_tally_until_its_voter_casts_again() {
2329        let key = ed25519_dalek::SigningKey::from_bytes(&[8; 32]);
2330        let sig = ballot_sig::sign_bound(&key, "demo-w", "reviewer", "ship", "sess-r", "-");
2331        let authors = std::collections::BTreeSet::new();
2332        let (gone, _) = tally_of("demo-w", &[], std::slice::from_ref(&sig), &authors);
2333        assert!(gone.is_empty(), "{gone:?}");
2334        let rows = [serde_json::json!({"agent": "reviewer", "choice": "ship"})];
2335        let note = ballot_sig::withdrawn_line("reviewer", "sess-r", "-");
2336        let taken_back = [note.clone(), sig.clone()];
2337        let (counted, ignored) = tally_of("demo-w", &rows, &taken_back, &authors);
2338        assert!(counted.is_empty(), "{counted:?}");
2339        assert!(ignored.is_empty(), "{ignored:?}");
2340        assert!(
2341            session_cast_refusal("demo-w", "sess-r", "editor", &taken_back, &[]).is_none(),
2342            "another persona is recorded after a withdrawal"
2343        );
2344        // The voter that withdrew casts again, and that ballot counts.
2345        assert_eq!(
2346            session_cast_refusal("demo-w", "sess-r", "reviewer", &taken_back, &[]),
2347            None
2348        );
2349        let recast = ballot_sig::sign_bound(&key, "demo-w", "reviewer", "hold", "sess-r", "-");
2350        let notes = [recast, note, sig];
2351        let rows = [serde_json::json!({"agent": "reviewer", "choice": "hold"})];
2352        let (counted, _) = tally_of("demo-w", &rows, &notes, &authors);
2353        assert_eq!(counted.len(), 1, "{counted:?}");
2354        assert_eq!(counted[0]["choice"], "hold");
2355        // A third cast with no withdrawal says how to replace the ballot.
2356        let again = session_cast_refusal("demo-w", "sess-r", "reviewer", &notes, &[]).unwrap();
2357        assert!(
2358            again.contains("already cast a ballot on demo-w (sess-r): reviewer's, for hold.")
2359                && again.contains("`ljos vote demo-w --withdraw --as reviewer`")
2360                && again.contains("Nothing was cast"),
2361            "{again}"
2362        );
2363    }
2364
2365    #[test]
2366    fn a_withdrawn_seat_ballot_does_not_decide_a_push() {
2367        let key = ed25519_dalek::SigningKey::from_bytes(&[4; 32]);
2368        let ship = ballot_sig::sign_bound(&key, "cite-1", "seat-a", "ship", "sess-a", "-");
2369        let hold = ballot_sig::sign_bound(&key, "cite-1", "seat-b", "hold", "sess-b", "-");
2370        let withdrawn = ballot_sig::withdrawn_line("seat-a", "sess-a", "-");
2371        let rows = [
2372            serde_json::json!({"agent": "seat-a", "choice": "ship"}),
2373            serde_json::json!({"agent": "seat-b", "choice": "hold"}),
2374        ];
2375        let notes = vec![withdrawn, hold, ship];
2376        let authors = std::collections::BTreeSet::new();
2377        let (counted, _) = tally_of("cite-1", &rows, &notes, &authors);
2378        assert_eq!(counted.len(), 1, "{counted:?}");
2379        assert_eq!(counted[0]["agent"], "seat-b");
2380        assert_eq!(counted[0]["choice"], "hold");
2381        let personas = std::collections::BTreeSet::new();
2382        let (words, json) = crate::push_cite_reading_signed(
2383            "cite-1",
2384            &counted,
2385            &personas,
2386            "seat-c",
2387            Ok(Default::default()),
2388        );
2389        assert!(words.contains("decides hold"), "{words}");
2390        assert!(!words.contains("split"), "{words}");
2391        assert_eq!(json["push_cite"]["stands"].as_bool(), Some(true), "{json}");
2392        let (only, _) = tally_of("cite-1", &rows[..1], &notes, &authors);
2393        assert!(only.is_empty(), "{only:?}");
2394        let (words, json) = crate::push_cite_reading_signed(
2395            "cite-1",
2396            &only,
2397            &personas,
2398            "seat-c",
2399            Ok(Default::default()),
2400        );
2401        assert!(
2402            words.contains("does not stand") && words.contains("no seat voted"),
2403            "{words}"
2404        );
2405        assert_eq!(json["push_cite"]["stands"].as_bool(), Some(false), "{json}");
2406    }
2407}