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::run_captured("vissue", &["children", issue, "--json"]) else {
216        return Vec::new();
217    };
218    serde_json::from_str::<Vec<Value>>(&said.stdout)
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
1651fn run_job(job: &Job) -> Result<String> {
1652    if job.argv.is_empty() {
1653        anyhow::bail!("{} did not start: no argv", job.role);
1654    }
1655    let mut cmd = Command::new(&job.argv[0]);
1656    cmd.args(&job.argv[1..])
1657        .env("LJOS_HARNESS_SESSION", &job.session)
1658        .env("LJOS_SESSION_ID", &job.session)
1659        .env("SEAT_SESSION_ID", &job.session)
1660        .env("LJOS_PANEL_CHILD", "1")
1661        .env("LJOS_SEAT", &job.role)
1662        .env("VISSUE_AGENT", &job.role)
1663        .env_remove("LJOS_PROCESS_NONCE")
1664        .stdin(Stdio::null())
1665        .stdout(Stdio::piped())
1666        .stderr(Stdio::piped());
1667    match cmd.output() {
1668        Ok(out) => {
1669            let code = out.status.code().unwrap_or(1);
1670            Ok(format!(
1671                "{} session {} exited {code}\n",
1672                job.role, job.session
1673            ))
1674        }
1675        Err(e) => anyhow::bail!(
1676            "{} session {} did not start ({}): {e}",
1677            job.role,
1678            job.session,
1679            job.argv[0]
1680        ),
1681    }
1682}
1683
1684/// Spawn each designer and each persona as its own harness session.
1685/// Designers receive only the brief. `dry_run` prints the plan and starts
1686/// nothing. Picks from the runner CLIs installed on `PATH`, or uses a
1687/// configured command template (`--harness`, `LJOS_PANEL_HARNESS`,
1688/// `LJOS_PANEL_COMMAND`, `harnesses.toml`, or `ljos.toml`).
1689///
1690/// # Errors
1691///
1692/// There is nobody to spawn, or no runner CLI is available.
1693pub fn panel_run(
1694    issue: &str,
1695    harness: Option<&str>,
1696    designers: usize,
1697    dry_run: bool,
1698) -> Result<String> {
1699    if std::env::var_os("LJOS_PANEL_CHILD").is_some() {
1700        return Ok(format!(
1701            "panel run: {issue} is already inside a panel member; nothing spawned\n"
1702        ));
1703    }
1704    let personas = persona_names(issue);
1705    if designers == 0 && personas.is_empty() {
1706        anyhow::bail!(
1707            "panel run: {issue} has no designers and no personas; pass --designers N or seat a persona"
1708        );
1709    }
1710    let harness =
1711        crate::panel_runner::resolve_panel_run_harness_with(harness, dry_run, &crate::on_path)?;
1712    let jobs = plan(issue, &harness, designers, &personas);
1713    let mut out = format_plan(issue, &harness, &jobs, dry_run);
1714    if dry_run {
1715        out.push_str("dry run: nothing spawned\n");
1716        return Ok(out);
1717    }
1718    let at_once = crate::panel_concurrency().max(1);
1719    for chunk in jobs.chunks(at_once) {
1720        let mut handles = Vec::new();
1721        for job in chunk {
1722            let job = job.clone();
1723            handles.push(std::thread::spawn(move || run_job(&job)));
1724        }
1725        for handle in handles {
1726            match handle.join() {
1727                Ok(Ok(line)) => out.push_str(&line),
1728                Ok(Err(err)) => return Err(err),
1729                Err(_) => anyhow::bail!("a panel member thread stopped"),
1730            }
1731        }
1732    }
1733    Ok(out)
1734}
1735
1736/// The issue body a designer would be handed, for a test that checks the
1737/// prompt carries no other candidate.
1738#[must_use]
1739pub fn designer_prompt_for(issue: &str, n: usize, of: usize) -> String {
1740    designer_prompt(issue, n, of, &issue_brief(issue))
1741}
1742
1743#[cfg(test)]
1744mod tests {
1745    use super::*;
1746
1747    #[test]
1748    fn a_shell_casts_as_one_session_and_a_harness_as_one_root() {
1749        let row = TreeRow {
1750            shell: "b:10:1".into(),
1751            harness: "b:20:2".into(),
1752            session: "sess-root:agent-a".into(),
1753            state: CAST_STATE.into(),
1754        };
1755        assert_eq!(TreeRow::parse(&row.line()), Some(row.clone()));
1756        let tree = |shell: &str, harness: &str| CastTree {
1757            shell: Some(shell.into()),
1758            harness: Some(harness.into()),
1759        };
1760        assert_eq!(
1761            row.binds("sess-root:agent-b", &tree("b:10:1", "b:30:3")),
1762            Some(("shell", "sess-root:agent-a".to_string())),
1763            "the same shell binds the later session to the earlier voice"
1764        );
1765        assert_eq!(
1766            row.binds("sess-root:agent-a", &tree("b:10:1", "b:20:2")),
1767            None,
1768            "the same session is the cast gate's to judge"
1769        );
1770        assert_eq!(
1771            row.binds("sess-root:agent-b", &tree("b:11:1", "b:20:2")),
1772            None,
1773            "a subagent of the same root counts with its parent"
1774        );
1775        assert_eq!(
1776            row.binds("sess-other", &tree("b:11:1", "b:20:2")),
1777            Some(("harness", "sess-root:agent-a".to_string())),
1778            "another root session on the same harness is the earlier voice"
1779        );
1780        assert_eq!(row.binds("sess-other", &CastTree::default()), None);
1781        assert!(!row.live(), "a row naming no running process is dropped");
1782    }
1783
1784    #[test]
1785    fn a_linked_ballot_folds_into_the_earlier_voice() {
1786        let k = ed25519_dalek::SigningKey::from_bytes(&[8; 32]);
1787        let first = ballot_sig::sign_bound(&k, "demo-tree", "alice", "ship", "sess-a", "-");
1788        let second = ballot_sig::sign_linked(
1789            &k,
1790            "demo-tree",
1791            "bea",
1792            "hold",
1793            ballot_sig::LinkedFields {
1794                harness: "sess-a",
1795                nonce: "-",
1796                claimed_harness: "sess-b",
1797                claimed_nonce: "-",
1798                link: "shell",
1799            },
1800        );
1801        let rows = [
1802            serde_json::json!({"agent": "alice", "choice": "ship"}),
1803            serde_json::json!({"agent": "bea", "choice": "hold"}),
1804        ];
1805        let tally = tally_voices(
1806            "demo-tree",
1807            &rows,
1808            &[second, first],
1809            &std::collections::BTreeSet::new(),
1810        );
1811        assert_eq!(tally.counted.len(), 1, "{:?}", tally.counted);
1812        assert!(
1813            tally
1814                .folds
1815                .iter()
1816                .any(|line| line.contains("folded into one voice (same shell sess-a)")),
1817            "{:?}",
1818            tally.folds
1819        );
1820        let apart = [
1821            ballot_sig::sign_bound(&k, "demo-tree", "carol", "ship", "sess-c", "-"),
1822            ballot_sig::sign_bound(&k, "demo-tree", "dora", "ship", "sess-d", "-"),
1823        ];
1824        let rows = [
1825            serde_json::json!({"agent": "carol", "choice": "ship"}),
1826            serde_json::json!({"agent": "dora", "choice": "ship"}),
1827        ];
1828        let (counted, _) = tally_of(
1829            "demo-tree",
1830            &rows,
1831            &apart,
1832            &std::collections::BTreeSet::new(),
1833        );
1834        assert_eq!(counted.len(), 2, "{counted:?}");
1835    }
1836
1837    fn seat(voter: &str, harness: &str) -> BallotTrace {
1838        BallotTrace {
1839            voter: voter.into(),
1840            harness: harness.into(),
1841            nonce: format!("n-{voter}"),
1842        }
1843    }
1844
1845    #[test]
1846    fn one_connection_cannot_cast_again_by_naming_another_seat() {
1847        let key = ed25519_dalek::SigningKey::from_bytes(&[3; 32]);
1848        let ballot = ballot_sig::sign_bound(
1849            &key,
1850            "demo-ind1",
1851            "alice",
1852            "ship",
1853            "sess-shared-0001",
1854            "n-alice",
1855        );
1856        let own = [ballot];
1857        let family = [(
1858            "demo-vote".to_string(),
1859            "candidate-sig: demo-d001 alice sess-author-0001 n-author - ab cd".to_string(),
1860        )];
1861        assert!(
1862            session_cast_refusal("demo-ind1", "sess-shared-0001", "bea", &own, &family).is_none(),
1863            "another persona from this session is recorded"
1864        );
1865        let recast =
1866            session_cast_refusal("demo-ind1", "sess-shared-0001", "alice", &own, &family).unwrap();
1867        assert!(
1868            recast.contains("already cast") && recast.contains("Nothing was cast"),
1869            "{recast}"
1870        );
1871        let author =
1872            session_cast_refusal("demo-vote", "sess-author-0001", "alice", &[], &family).unwrap();
1873        assert!(author.contains("authored candidate demo-d001"), "{author}");
1874        let own_candidate =
1875            ["candidate-sig: demo-d001 alice sess-author-0001 n-author - ab cd".to_string()];
1876        let on_own = session_cast_refusal(
1877            "demo-vote",
1878            "sess-author-0001",
1879            "alice",
1880            &own_candidate,
1881            &[],
1882        )
1883        .unwrap();
1884        assert!(on_own.contains("authored candidate demo-d001"), "{on_own}");
1885        assert!(session_cast_refusal("demo-ind1", "sess-bob-0001", "bob", &own, &family).is_none());
1886        let _g = crate::test_env_lock();
1887        let dir = tempfile::tempdir().unwrap();
1888        std::env::set_var("VISSUE_ROOT", dir.path());
1889        std::env::set_var("LJOS_HARNESS_SESSION", "sess-live-0001");
1890        std::env::set_var("LJOS_PROCESS_NONCE", "nonce-live-0001");
1891        let first = claim_session_ballot("probe-seat-1", "saddle").unwrap();
1892        let second = claim_session_ballot("probe-seat-1", "other");
1893        assert!(second.is_ok(), "{second:?}");
1894        let again = claim_session_ballot("probe-seat-1", "saddle").unwrap_err();
1895        assert!(again.to_string().contains("already cast"), "{again:#}");
1896        first.keep();
1897        std::env::remove_var("VISSUE_ROOT");
1898        std::env::remove_var("LJOS_HARNESS_SESSION");
1899        std::env::remove_var("LJOS_PROCESS_NONCE");
1900    }
1901
1902    #[test]
1903    fn a_signature_for_this_issue_on_its_family_still_counts() {
1904        let key = ed25519_dalek::SigningKey::from_bytes(&[4; 32]);
1905        let on_b = ballot_sig::sign_bound(&key, "demo-b", "saddle", "ship", "sess-one", "-");
1906        let stray = [("parent-b".to_string(), on_b.clone())];
1907        assert!(
1908            session_cast_refusal("demo-b", "sess-one", "bea", &[], &stray).is_none(),
1909            "another persona is recorded beside saddle"
1910        );
1911        let withdrawn = ballot_sig::withdrawn_line("saddle", "sess-one", "-");
1912        let own = [withdrawn, on_b.clone()];
1913        assert!(
1914            session_cast_refusal("demo-b", "sess-one", "saddle", &own, &stray).is_none(),
1915            "the issue's own copy in the family is read once, with its withdrawal"
1916        );
1917    }
1918
1919    #[test]
1920    fn a_parent_cut_for_a_child_issue_is_read_on_the_parent_index() {
1921        let key = ed25519_dalek::SigningKey::from_bytes(&[6; 32]);
1922        let issue = "child-a";
1923        let parent = "parent-1";
1924        let own = [ballot_sig::sign_bound(
1925            &key, issue, "bea", "ship", "sess-bea", "-",
1926        )];
1927        let sig = ballot_sig::sign_bound(&key, issue, "saddle", "ship", "sess-saddle", "-");
1928        let cut = ballot_sig::withdrawn_line_on("saddle", "sess-saddle", "-", Some(issue));
1929        let family = [(parent.to_string(), cut), (parent.to_string(), sig)];
1930        assert_eq!(
1931            session_cast_refusal(issue, "sess-saddle", "saddle", &own, &family),
1932            None,
1933            "a parent cut for child-a must not be indexed against child-a's own notes"
1934        );
1935    }
1936
1937    #[test]
1938    fn a_choice_with_a_space_is_still_this_sessions_one_ballot() {
1939        // The panel eval's unscored case on 7e21d3c: session spaced-1 cast
1940        // `beside binary` as seat-a, then `xdg home`, and both landed.
1941        let k = ed25519_dalek::SigningKey::from_bytes(&[9; 32]);
1942        let first =
1943            ballot_sig::sign_bound(&k, "panel-spcd", "seat-a", "beside binary", "spaced-1", "-");
1944        let again = session_cast_refusal(
1945            "panel-spcd",
1946            "spaced-1",
1947            "seat-a",
1948            std::slice::from_ref(&first),
1949            &[],
1950        )
1951        .expect("a second cast from spaced-1 was let through");
1952        assert!(
1953            again.contains("already cast") && again.contains("Nothing was cast"),
1954            "{again}"
1955        );
1956        assert!(session_cast_refusal(
1957            "panel-spcd",
1958            "other-sess",
1959            "seat-b",
1960            std::slice::from_ref(&first),
1961            &[],
1962        )
1963        .is_none());
1964        // The drawer already has both, as the eval left it. The oldest
1965        // signature is the choice that counts.
1966        let later = ballot_sig::sign_bound(&k, "panel-spcd", "seat-a", "xdg home", "spaced-1", "-");
1967        let rows = [serde_json::json!({"agent": "seat-a", "choice": "xdg home"})];
1968        let (counted, _) = tally_of(
1969            "panel-spcd",
1970            &rows,
1971            &[later, first],
1972            &std::collections::BTreeSet::new(),
1973        );
1974        assert_eq!(counted.len(), 1, "{counted:?}");
1975        assert_eq!(counted[0]["choice"], "beside binary");
1976        // A note that does not parse, and does not verify, does not bind.
1977        let odd = "ballot-sig: seat-a beside binary ab cd spaced-1 -".to_string();
1978        assert!(session_cast_refusal("panel-spcd", "spaced-1", "seat-a", &[odd], &[]).is_none());
1979    }
1980
1981    #[test]
1982    fn five_personas_one_sand_session_count_as_the_first_ballot() {
1983        let session = "sand-subagent-fcdb9c57-9988-7d91-4311-9fc5e42f7529";
1984        let personas = ["academic", "transit", "safety", "cat-owner", "finance"];
1985        let mut notes = Vec::new();
1986        // Newest first, as the logbook stores them: the 11:46 recast, then
1987        // the 10:35 ballots, academic's first cast last.
1988        for persona in personas.iter().rev() {
1989            notes.push(format!(
1990                "ballot-sig: {persona} bus ab cd {session} n-{persona}-2"
1991            ));
1992        }
1993        for persona in personas.iter().rev() {
1994            notes.push(format!(
1995                "ballot-sig: {persona} rail ab cd {session} n-{persona}-1"
1996            ));
1997        }
1998        let rows: Vec<Value> = personas
1999            .iter()
2000            .map(|persona| serde_json::json!({"agent": persona, "choice": "bus"}))
2001            .collect();
2002        let tally = tally_voices(
2003            "travel-rx1d",
2004            &rows,
2005            &notes,
2006            &std::collections::BTreeSet::new(),
2007        );
2008        assert_eq!(tally.counted.len(), 1, "{:?}", tally.counted);
2009        assert_eq!(tally.counted[0]["agent"], "finance");
2010        assert_eq!(tally.counted[0]["choice"], "rail");
2011        assert!(tally.ignored.is_empty(), "{:?}", tally.ignored);
2012        let said = provenance_text(&notes, &rows);
2013        assert!(
2014            said.contains(&format!("shared parent {session}:")),
2015            "{said}"
2016        );
2017        let folded = tally.folds.join("\n");
2018        assert!(
2019            folded.contains(&format!("same session {session}"))
2020                && personas.iter().all(|persona| folded.contains(persona)),
2021            "{folded}"
2022        );
2023        let mut authors = std::collections::BTreeSet::new();
2024        authors.insert(session.to_string());
2025        let (none, dropped) = tally_of("travel-rx1d", &rows, &notes, &authors);
2026        assert!(none.is_empty(), "{none:?}");
2027        assert_eq!(dropped.len(), 5, "{dropped:?}");
2028        assert!(dropped
2029            .iter()
2030            .all(|item| item.why == "authored a candidate"));
2031    }
2032
2033    fn note_of(voter: &str, choice: &str, harness: &str) -> String {
2034        format!("ballot-sig: {voter} {choice} ab cd {harness} -")
2035    }
2036
2037    #[test]
2038    fn one_session_that_switches_seats_counts_the_latest_ballot() {
2039        let session = "sess-switch-0001";
2040        let notes = vec![
2041            note_of("bob", "hold", session),
2042            note_of("alice", "ship", session),
2043        ];
2044        let rows = [
2045            serde_json::json!({"agent": "alice", "choice": "ship"}),
2046            serde_json::json!({"agent": "bob", "choice": "hold"}),
2047        ];
2048        let tally = tally_voices(
2049            "demo-switch",
2050            &rows,
2051            &notes,
2052            &std::collections::BTreeSet::new(),
2053        );
2054        assert_eq!(tally.counted.len(), 1, "{:?}", tally.counted);
2055        assert_eq!(tally.counted[0]["agent"], "bob");
2056        assert_eq!(tally.counted[0]["choice"], "hold");
2057        let folded = tally.folds.join("\n");
2058        assert!(
2059            folded.contains(&format!("same session {session}"))
2060                && folded.contains("alice")
2061                && folded.contains("bob")
2062                && folded.contains("counted bob"),
2063            "{folded}"
2064        );
2065    }
2066
2067    #[test]
2068    fn three_subagents_of_one_parent_are_one_voice() {
2069        let parent = "sess-parent-0001";
2070        let notes = vec![
2071            note_of("cee", "ship", &format!("{parent}:agent-c")),
2072            note_of("bee", "hold", &format!("{parent}:agent-b")),
2073            note_of("aye", "ship", &format!("{parent}:agent-a")),
2074        ];
2075        let rows = [
2076            serde_json::json!({"agent": "aye", "choice": "ship"}),
2077            serde_json::json!({"agent": "bee", "choice": "hold"}),
2078            serde_json::json!({"agent": "cee", "choice": "ship"}),
2079        ];
2080        let tally = tally_voices(
2081            "demo-three",
2082            &rows,
2083            &notes,
2084            &std::collections::BTreeSet::new(),
2085        );
2086        assert_eq!(tally.counted.len(), 1, "{:?}", tally.counted);
2087        assert_eq!(tally.counted[0]["agent"], "cee");
2088        assert_eq!(tally.counted[0]["choice"], "ship");
2089        let folded = tally.folds.join("\n");
2090        assert!(
2091            folded.contains(&format!("same parent {parent}"))
2092                && folded.contains("aye")
2093                && folded.contains("bee")
2094                && folded.contains("cee"),
2095            "{folded}"
2096        );
2097    }
2098
2099    #[test]
2100    fn two_sessions_count_as_two_voices() {
2101        let notes = vec![
2102            note_of("bob", "hold", "sess-bob-0001"),
2103            note_of("alice", "ship", "sess-alice-0001"),
2104        ];
2105        let rows = [
2106            serde_json::json!({"agent": "alice", "choice": "ship"}),
2107            serde_json::json!({"agent": "bob", "choice": "hold"}),
2108        ];
2109        let tally = tally_voices(
2110            "demo-two",
2111            &rows,
2112            &notes,
2113            &std::collections::BTreeSet::new(),
2114        );
2115        assert_eq!(tally.counted.len(), 2, "{:?}", tally.counted);
2116        assert!(tally.folds.is_empty(), "{:?}", tally.folds);
2117    }
2118
2119    #[test]
2120    fn one_session_of_n_seats_is_weighed() {
2121        let ballots = vec![
2122            seat("carol", "sess-shared-0001"),
2123            seat("alice", "sess-shared-0001"),
2124            seat("bob", "sess-shared-0001"),
2125        ];
2126        assert_eq!(refusal("demo-ind1", &ballots, &[]), None);
2127    }
2128
2129    #[test]
2130    fn n_sessions_settle() {
2131        let ballots = vec![
2132            seat("alice", "sess-alice-0001"),
2133            seat("bob", "sess-bob-0001"),
2134            seat("carol", "sess-carol-0001"),
2135        ];
2136        assert_eq!(refusal("demo-ind2", &ballots, &[]), None);
2137    }
2138
2139    #[test]
2140    fn an_author_who_votes_is_refused_even_when_the_other_ballots_are_not_theirs() {
2141        let ballots = vec![
2142            seat("alice", "sess-alice-0001"),
2143            seat("bob", "sess-bob-0001"),
2144        ];
2145        let candidates = vec![CandidateTrace {
2146            id: "demo-d001".into(),
2147            harness: "sess-alice-0001".into(),
2148            nonce: "n-alice".into(),
2149            saw: vec![],
2150        }];
2151        let msg = refusal("demo-vote", &ballots, &candidates).unwrap();
2152        assert_eq!(
2153            msg,
2154            "refuses to settle demo-vote: session sess-alice-0001 authored candidate demo-d001 \
2155             and voted on demo-vote"
2156                .to_string()
2157        );
2158    }
2159
2160    #[test]
2161    fn a_session_that_read_another_candidate_is_flagged() {
2162        let reads = vec![
2163            ("sess-1".into(), "demo-b".into()),
2164            ("sess-2".into(), "demo-a".into()),
2165        ];
2166        let saw = reads_that_spoil("sess-1", &reads, &["demo-a".into(), "demo-b".into()]);
2167        assert_eq!(saw, vec!["demo-b".to_string()]);
2168        let line = blind_sentence("demo-a", "sess-1", &saw);
2169        assert_eq!(
2170            line,
2171            "design demo-a is not blind: session sess-1 read demo-b before filing it"
2172        );
2173        let quiet = reads_that_spoil("sess-2", &reads, &["demo-b".into()]);
2174        assert!(quiet.is_empty(), "{quiet:?}");
2175    }
2176
2177    #[test]
2178    fn a_designer_prompt_is_only_the_brief() {
2179        let prompt = designer_prompt("demo-1", 1, 3, "Ship the cache.\nKeep the ledger.");
2180        assert!(prompt.contains("== brief"), "{prompt}");
2181        assert!(prompt.contains("Ship the cache."), "{prompt}");
2182        assert!(prompt.contains("Do not read another design"), "{prompt}");
2183        assert!(prompt.contains("Do not vote"), "{prompt}");
2184        assert!(!prompt.contains("OTHER-CANDIDATE-BODY"), "{prompt}");
2185        let argv = harness_argv("cursor-agent", "hello");
2186        assert_eq!(argv, vec!["cursor-agent", "-p", "hello"]);
2187        let claude = harness_argv("claude", "hello");
2188        assert_eq!(&claude[..3], ["claude", "-p", "hello"]);
2189    }
2190
2191    #[test]
2192    fn unbound_ballots_are_left_alone() {
2193        let ballots = vec![
2194            BallotTrace {
2195                voter: "alice".into(),
2196                harness: String::new(),
2197                nonce: String::new(),
2198            },
2199            BallotTrace {
2200                voter: "bob".into(),
2201                harness: "-".into(),
2202                nonce: "-".into(),
2203            },
2204        ];
2205        assert_eq!(refusal("old-tracker", &ballots, &[]), None);
2206    }
2207
2208    #[test]
2209    fn the_same_nonce_without_a_harness_is_one_session() {
2210        let ballots = vec![
2211            BallotTrace {
2212                voter: "alice".into(),
2213                harness: "-".into(),
2214                nonce: "same-nonce".into(),
2215            },
2216            BallotTrace {
2217                voter: "bob".into(),
2218                harness: "-".into(),
2219                nonce: "same-nonce".into(),
2220            },
2221        ];
2222        assert_eq!(session_key("-", "same-nonce"), "nonce:same-nonce");
2223        assert_eq!(refusal("demo-n", &ballots, &[]), None);
2224    }
2225
2226    #[test]
2227    fn a_seat_only_session_keeps_the_seat_as_its_key() {
2228        assert_eq!(
2229            session_key("ljosbot", ballot_sig::SEAT_NONCE),
2230            session_key("ljosbot", "-")
2231        );
2232        let key = ed25519_dalek::SigningKey::from_bytes(&[9; 32]);
2233        // A ballot the seat cast before the marker still holds its session.
2234        let old = ballot_sig::sign_bound(&key, "demo-s", "alice", "ship", "ljosbot", "-");
2235        assert!(
2236            session_cast_refusal("demo-s", "ljosbot", "bea", std::slice::from_ref(&old), &[])
2237                .is_none()
2238        );
2239        let again = session_cast_refusal("demo-s", "ljosbot", "alice", &[old], &[]).unwrap();
2240        assert!(again.contains("already cast"), "{again}");
2241        assert!(!again.contains("unverified"), "{again}");
2242        let marked = ballot_sig::sign_bound(
2243            &key,
2244            "demo-s",
2245            "alice",
2246            "ship",
2247            "ljosbot",
2248            ballot_sig::SEAT_NONCE,
2249        );
2250        assert!(session_cast_refusal(
2251            "demo-s",
2252            "ljosbot",
2253            "bea",
2254            std::slice::from_ref(&marked),
2255            &[],
2256        )
2257        .is_none());
2258        let again = session_cast_refusal("demo-s", "ljosbot", "alice", &[marked], &[]).unwrap();
2259        assert!(
2260            again.contains("already cast")
2261                && again.contains(&ballot_sig::unverified_sentence("ljosbot")),
2262            "{again}"
2263        );
2264    }
2265
2266    #[test]
2267    fn a_siblings_signature_does_not_block_this_issue() {
2268        let key = ed25519_dalek::SigningKey::from_bytes(&[7; 32]);
2269        let sibling =
2270            ballot_sig::sign_bound(&key, "rgpot-9ot3", "saddle", "ship", "sess-saddle", "-");
2271        assert!(session_cast_refusal(
2272            "eOn-1stn",
2273            "sess-saddle",
2274            "saddle",
2275            &[],
2276            &[("rgpot-9ot3".to_string(), sibling.clone())],
2277        )
2278        .is_none());
2279        assert!(
2280            session_cast_refusal(
2281                "eOn-1stn",
2282                "sess-saddle",
2283                "saddle",
2284                std::slice::from_ref(&sibling),
2285                &[],
2286            )
2287            .is_none(),
2288            "a signature for another issue does not verify here"
2289        );
2290        let here = ballot_sig::sign_bound(&key, "eOn-1stn", "saddle", "ship", "sess-saddle", "-");
2291        let blocked = session_cast_refusal(
2292            "eOn-1stn",
2293            "sess-saddle",
2294            "saddle",
2295            std::slice::from_ref(&here),
2296            &[],
2297        )
2298        .unwrap();
2299        assert!(
2300            blocked.contains("already cast") && blocked.contains("Nothing was cast"),
2301            "{blocked}"
2302        );
2303    }
2304
2305    #[test]
2306    fn a_withdrawn_ballot_drops_out_of_the_tally_until_its_voter_casts_again() {
2307        let key = ed25519_dalek::SigningKey::from_bytes(&[8; 32]);
2308        let sig = ballot_sig::sign_bound(&key, "demo-w", "reviewer", "ship", "sess-r", "-");
2309        let authors = std::collections::BTreeSet::new();
2310        let (gone, _) = tally_of("demo-w", &[], std::slice::from_ref(&sig), &authors);
2311        assert!(gone.is_empty(), "{gone:?}");
2312        let rows = [serde_json::json!({"agent": "reviewer", "choice": "ship"})];
2313        let note = ballot_sig::withdrawn_line("reviewer", "sess-r", "-");
2314        let taken_back = [note.clone(), sig.clone()];
2315        let (counted, ignored) = tally_of("demo-w", &rows, &taken_back, &authors);
2316        assert!(counted.is_empty(), "{counted:?}");
2317        assert!(ignored.is_empty(), "{ignored:?}");
2318        assert!(
2319            session_cast_refusal("demo-w", "sess-r", "editor", &taken_back, &[]).is_none(),
2320            "another persona is recorded after a withdrawal"
2321        );
2322        // The voter that withdrew casts again, and that ballot counts.
2323        assert_eq!(
2324            session_cast_refusal("demo-w", "sess-r", "reviewer", &taken_back, &[]),
2325            None
2326        );
2327        let recast = ballot_sig::sign_bound(&key, "demo-w", "reviewer", "hold", "sess-r", "-");
2328        let notes = [recast, note, sig];
2329        let rows = [serde_json::json!({"agent": "reviewer", "choice": "hold"})];
2330        let (counted, _) = tally_of("demo-w", &rows, &notes, &authors);
2331        assert_eq!(counted.len(), 1, "{counted:?}");
2332        assert_eq!(counted[0]["choice"], "hold");
2333        // A third cast with no withdrawal says how to replace the ballot.
2334        let again = session_cast_refusal("demo-w", "sess-r", "reviewer", &notes, &[]).unwrap();
2335        assert!(
2336            again.contains("already cast a ballot on demo-w (sess-r): reviewer's, for hold.")
2337                && again.contains("`ljos vote demo-w --withdraw --as reviewer`")
2338                && again.contains("Nothing was cast"),
2339            "{again}"
2340        );
2341    }
2342
2343    #[test]
2344    fn a_withdrawn_seat_ballot_does_not_decide_a_push() {
2345        let key = ed25519_dalek::SigningKey::from_bytes(&[4; 32]);
2346        let ship = ballot_sig::sign_bound(&key, "cite-1", "seat-a", "ship", "sess-a", "-");
2347        let hold = ballot_sig::sign_bound(&key, "cite-1", "seat-b", "hold", "sess-b", "-");
2348        let withdrawn = ballot_sig::withdrawn_line("seat-a", "sess-a", "-");
2349        let rows = [
2350            serde_json::json!({"agent": "seat-a", "choice": "ship"}),
2351            serde_json::json!({"agent": "seat-b", "choice": "hold"}),
2352        ];
2353        let notes = vec![withdrawn, hold, ship];
2354        let authors = std::collections::BTreeSet::new();
2355        let (counted, _) = tally_of("cite-1", &rows, &notes, &authors);
2356        assert_eq!(counted.len(), 1, "{counted:?}");
2357        assert_eq!(counted[0]["agent"], "seat-b");
2358        assert_eq!(counted[0]["choice"], "hold");
2359        let personas = std::collections::BTreeSet::new();
2360        let (words, json) = crate::push_cite_reading_signed(
2361            "cite-1",
2362            &counted,
2363            &personas,
2364            "seat-c",
2365            Ok(Default::default()),
2366        );
2367        assert!(words.contains("decides hold"), "{words}");
2368        assert!(!words.contains("split"), "{words}");
2369        assert_eq!(json["push_cite"]["stands"].as_bool(), Some(true), "{json}");
2370        let (only, _) = tally_of("cite-1", &rows[..1], &notes, &authors);
2371        assert!(only.is_empty(), "{only:?}");
2372        let (words, json) = crate::push_cite_reading_signed(
2373            "cite-1",
2374            &only,
2375            &personas,
2376            "seat-c",
2377            Ok(Default::default()),
2378        );
2379        assert!(
2380            words.contains("does not stand") && words.contains("no seat voted"),
2381            "{words}"
2382        );
2383        assert_eq!(json["push_cite"]["stands"].as_bool(), Some(false), "{json}");
2384    }
2385}