Skip to main content

mecha_core/
questions.rs

1//! Questions a run could not answer for itself, kept until someone does.
2//!
3//! **The inbound twin of the outbox, and it exists for the same reason that
4//! does.** A staged send is a run's *outbound* act surviving the run's end —
5//! written by one process, released by another, hours later. Nothing let a
6//! run's *question* do the same: `ask_user` parks the run itself, for ten
7//! minutes, and then declines. That is the right shape for a present human, a
8//! page open in a hand. It is the wrong one for a delegated task, where the
9//! honest case is that nobody answers until morning.
10//!
11//! So a delegated run that needs an answer **ends**. The partial work is kept,
12//! the question is stored here, and the task's `waiting_on` moves from the
13//! agent to the owner. Answering resumes the session with the answer as the
14//! next user turn, and the ball moves back.
15//!
16//! Three things fall out of that arrangement rather than being added to it:
17//!
18//! - **The ball-passing is already modelled.** `waiting_on` alternating between
19//!   owner and agent is the GTD semantics the board has natively, so the
20//!   Waiting view becomes the queue of blocked delegations with no new noun.
21//! - **No slot is held.** A parked run occupies one of four llama-server slots
22//!   and a cached prefix for ten minutes doing nothing. Ending releases both,
23//!   and leaves the prefix in the prompt cache for the resume to find.
24//! - **It is a queue, so it is counted.** An unanswered question is exactly the
25//!   sort of store that reaches 6,434 items without anybody deciding to let it,
26//!   which is the incident `/queues` exists because of.
27//!
28//! Deliberately **not** a second approval surface: nothing here is approved.
29//! It is a question store, and the only thing a person does to an item is
30//! answer it or give up on it.
31//!
32//! Store conventions are the outbox's, for the same reasons: one pretty JSON
33//! per item so `$EDITOR` and `git diff` work on it, temp-sibling-and-rename so
34//! a reader never sees half a record, an advisory flock for writers, and
35//! **nothing is ever deleted** — an answered question is the record of how the
36//! run got unstuck.
37
38use anyhow::{Context, Result};
39use serde::{Deserialize, Serialize};
40use std::path::{Path, PathBuf};
41
42use crate::agent::Taint;
43use crate::session::Session;
44
45/// One question, and everything needed to put its answer back where it came
46/// from.
47#[derive(Debug, Clone, Serialize, Deserialize)]
48pub struct Question {
49    pub id: String,
50    /// `open` | `answered` | `abandoned`.
51    pub status: String,
52    /// The question as the model put it.
53    pub question: String,
54    /// Concrete choices, when the model was confident the answer is one of
55    /// them. Never exhaustive — the tool's own description says so, and an
56    /// answer outside the list is ordinary.
57    #[serde(default)]
58    pub options: Vec<String>,
59    /// The conversation this came out of.
60    ///
61    /// **Required, unlike the outbox's**, and the difference is the whole
62    /// design: a staged send is executable on its own, so its session is
63    /// provenance. An answer is only meaningful as the next turn of the
64    /// conversation that asked, so a question with no session is one nobody
65    /// can answer — it would be a note, not a question.
66    pub session_id: String,
67    /// The board item the run was working, when it was working one.
68    #[serde(default)]
69    pub task_id: Option<String>,
70    /// The jail the asking run was held to, so the resume runs where the
71    /// question was asked. The outbox's recorded-jail rule, for the same
72    /// reason: a deferred continuation resolved against a different root is a
73    /// different run.
74    #[serde(default)]
75    pub workspace: Option<PathBuf>,
76    /// The conversation's taint when the question was asked.
77    ///
78    /// **A question is an inbound request for information, composed by a model
79    /// that may have been reading third-party text.** "What is the API key for
80    /// the deploy?" is a perfectly well-formed question and an injection's
81    /// dream, so an armed snapshot has to reach the person the same way it
82    /// does on a staged draft — the outbox warns before a send, and this
83    /// warns before an answer.
84    #[serde(default)]
85    pub taint: Taint,
86    pub asked_at: String,
87    #[serde(default)]
88    pub answered_at: Option<String>,
89    /// What the owner said. `None` while open, and on an abandoned question —
90    /// giving up is not an answer, and recording it as one would put words in
91    /// their mouth in the transcript the resume writes.
92    #[serde(default)]
93    pub answer: Option<String>,
94}
95
96impl Question {
97    pub fn is_open(&self) -> bool {
98        self.status == "open"
99    }
100
101    /// One line for a listing.
102    pub fn summary(&self) -> String {
103        let q = self.question.trim().replace('\n', " ");
104        let q: String = q.chars().take(72).collect();
105        match &self.task_id {
106            Some(t) => format!("{q}  ({t})"),
107            None => q,
108        }
109    }
110}
111
112pub struct QuestionStore {
113    root: PathBuf,
114}
115
116/// Holds the store's writer lock for as long as it lives.
117pub struct QuestionLock {
118    _file: std::fs::File,
119}
120
121impl QuestionStore {
122    pub fn default_root() -> Result<PathBuf> {
123        if let Ok(dir) = std::env::var("MECHA_QUESTIONS_DIR") {
124            return Ok(PathBuf::from(dir));
125        }
126        Ok(crate::work::mecha_home()?.join("questions"))
127    }
128
129    pub fn open(root: impl Into<PathBuf>) -> Result<Self> {
130        let root = root.into();
131        crate::create_private_dir(&root).with_context(|| format!("creating {}", root.display()))?;
132        Ok(QuestionStore { root })
133    }
134
135    /// Open at the default location only if it already exists — for read paths
136    /// that must not create state as a side effect. Doctor's rule: an
137    /// examination that creates what it was about to report is measuring
138    /// itself.
139    pub fn open_existing_default() -> Option<Self> {
140        let root = Self::default_root().ok()?;
141        root.is_dir().then_some(QuestionStore { root })
142    }
143
144    pub fn root(&self) -> &Path {
145        &self.root
146    }
147
148    fn path(&self, id: &str) -> PathBuf {
149        self.root.join(format!("{id}.json"))
150    }
151
152    /// Take the writer lock. Held across a read-modify-write, never across an
153    /// `$EDITOR` or a run.
154    pub fn lock(&self) -> Result<QuestionLock> {
155        use std::os::unix::io::AsRawFd;
156        let file = std::fs::OpenOptions::new()
157            .create(true)
158            .truncate(false)
159            .write(true)
160            .open(self.root.join(".lock"))?;
161        // SAFETY: flock on an fd we own, held open by the returned guard.
162        if unsafe { libc::flock(file.as_raw_fd(), libc::LOCK_EX) } != 0 {
163            return Err(std::io::Error::last_os_error()).context("locking the question store");
164        }
165        Ok(QuestionLock { _file: file })
166    }
167
168    /// Every question, newest first. An unreadable record is skipped rather
169    /// than failing the listing — one corrupt file must not hide the queue.
170    pub fn items(&self) -> Result<Vec<Question>> {
171        let mut out = Vec::new();
172        let dir = match std::fs::read_dir(&self.root) {
173            Ok(d) => d,
174            Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(out),
175            Err(e) => return Err(e).context("reading the question store"),
176        };
177        for entry in dir.flatten() {
178            let path = entry.path();
179            if path.extension().and_then(|e| e.to_str()) != Some("json") {
180                continue;
181            }
182            match std::fs::read_to_string(&path)
183                .ok()
184                .and_then(|t| serde_json::from_str::<Question>(&t).ok())
185            {
186                Some(q) => out.push(q),
187                None => tracing::warn!(path = %path.display(), "skipping unreadable question"),
188            }
189        }
190        out.sort_by(|a, b| b.asked_at.cmp(&a.asked_at));
191        Ok(out)
192    }
193
194    pub fn open_items(&self) -> Result<Vec<Question>> {
195        Ok(self
196            .items()?
197            .into_iter()
198            .filter(Question::is_open)
199            .collect())
200    }
201
202    pub fn get(&self, id: &str) -> Result<Question> {
203        let text = std::fs::read_to_string(self.path(id))
204            .with_context(|| format!("no such question: {id}"))?;
205        serde_json::from_str(&text).with_context(|| format!("unreadable question: {id}"))
206    }
207
208    /// The distinguishing tail of an id, for display.
209    ///
210    /// **Not a prefix**, and that is the whole point. `Session::new_id` is
211    /// `YYYYMMDDTHHMMSS-xxxxxxxx`, so the leading characters are a timestamp:
212    /// abbreviating to the first eight gives every question asked *on the same
213    /// day* the identical handle. Printed one, it looked like an id and was a
214    /// date. `serve::resume_key` reached the same conclusion about the same
215    /// ids and takes the tail too.
216    pub fn short(id: &str) -> &str {
217        id.rsplit_once('-').map(|(_, tail)| tail).unwrap_or(id)
218    }
219
220    /// Resolve an abbreviated id — head or tail — and refuse an ambiguous one
221    /// rather than guessing, the way the outbox and sessions do.
222    pub fn find(&self, needle: &str) -> Result<Question> {
223        if let Ok(q) = self.get(needle) {
224            return Ok(q);
225        }
226        // Both ends, because the useful abbreviation is the tail and the
227        // habitual one is the head. Accepting only what this store prints
228        // would refuse a perfectly unambiguous id somebody copied whole from
229        // a session line.
230        let matches: Vec<Question> = self
231            .items()?
232            .into_iter()
233            .filter(|q| q.id.starts_with(needle) || q.id.ends_with(needle))
234            .collect();
235        match matches.len() {
236            0 => anyhow::bail!("no such question: {needle}"),
237            1 => Ok(matches.into_iter().next().expect("just checked")),
238            n => anyhow::bail!("{needle} matches {n} questions — use more of the id"),
239        }
240    }
241
242    pub fn put(&self, q: &Question) -> Result<()> {
243        let path = self.path(&q.id);
244        let tmp = path.with_extension("json.tmp");
245        std::fs::write(&tmp, serde_json::to_string_pretty(q)?)?;
246        std::fs::rename(&tmp, &path)?;
247        Ok(())
248    }
249
250    /// Record a question and hand back what was stored.
251    #[allow(clippy::too_many_arguments)]
252    pub fn park(
253        &self,
254        question: &str,
255        options: Vec<String>,
256        session_id: &str,
257        task_id: Option<String>,
258        workspace: Option<PathBuf>,
259        taint: Taint,
260    ) -> Result<Question> {
261        let q = Question {
262            id: Session::new_id(),
263            status: "open".into(),
264            question: question.to_string(),
265            options,
266            session_id: session_id.to_string(),
267            task_id,
268            workspace,
269            taint,
270            asked_at: chrono::Utc::now().to_rfc3339(),
271            answered_at: None,
272            answer: None,
273        };
274        let _lock = self.lock()?;
275        self.put(&q)?;
276        Ok(q)
277    }
278
279    /// Record the owner's answer. Returns the question as it now stands.
280    ///
281    /// Answering does not itself resume anything — the caller does that, and
282    /// the split is deliberate: a store that started agent runs would be a
283    /// store that can spend the model, and this one is meant to be safe for a
284    /// listing to touch.
285    pub fn answer(&self, id: &str, answer: &str) -> Result<Question> {
286        let _lock = self.lock()?;
287        let mut q = self.find(id)?;
288        anyhow::ensure!(
289            q.is_open(),
290            "question {} is already {} — answering it again would resume a conversation that \
291             already moved on",
292            q.id,
293            q.status
294        );
295        q.status = "answered".into();
296        q.answered_at = Some(chrono::Utc::now().to_rfc3339());
297        q.answer = Some(answer.to_string());
298        self.put(&q)?;
299        Ok(q)
300    }
301
302    /// Give up on a question without answering it.
303    ///
304    /// The answer stays `None` on purpose. Abandoning is a decision about the
305    /// question, not a reply to it, and writing "abandoned" into the answer
306    /// would put words in the owner's mouth in the transcript a later resume
307    /// reads back.
308    pub fn abandon(&self, id: &str) -> Result<Question> {
309        let _lock = self.lock()?;
310        let mut q = self.find(id)?;
311        anyhow::ensure!(q.is_open(), "question {} is already {}", q.id, q.status);
312        q.status = "abandoned".into();
313        q.answered_at = Some(chrono::Utc::now().to_rfc3339());
314        self.put(&q)?;
315        Ok(q)
316    }
317}
318
319/// An [`Asker`] that stores the question and stops the run, instead of
320/// blocking on an answer that is not coming.
321///
322/// [`Asker`]: crate::tool::ask::Asker
323///
324/// **The run ends; it does not wait.** `ask_user`'s contract is that it never
325/// blocks forever, and the web's implementation honours that with a ten-minute
326/// timeout and then the tool's measured decline. For a delegated task neither
327/// half is right: ten minutes is far too short for "answer at breakfast", and
328/// a decline tells the model nobody was willing to answer when in truth nobody
329/// was *asked yet*.
330///
331/// Stopping is done through `ToolCtx::cancel`, which is the mechanism the
332/// harness already has for exactly this shape — it stops at the next safe
333/// point and **keeps the partial turn**, so the work done before the question
334/// survives into the transcript the resume reads back. It is not an abort.
335pub struct ParkingAsker {
336    store: std::sync::Arc<QuestionStore>,
337    session_id: String,
338    task_id: Option<String>,
339    parked: std::sync::Arc<std::sync::Mutex<Vec<String>>>,
340}
341
342impl ParkingAsker {
343    pub fn new(
344        store: std::sync::Arc<QuestionStore>,
345        session_id: impl Into<String>,
346        task_id: Option<String>,
347    ) -> Self {
348        ParkingAsker {
349            store,
350            session_id: session_id.into(),
351            task_id,
352            parked: Default::default(),
353        }
354    }
355
356    /// Ids parked during this run, in the order they were asked.
357    pub fn parked(&self) -> Vec<String> {
358        self.parked.lock().map(|p| p.clone()).unwrap_or_default()
359    }
360
361    /// Merge the conversation's post-run taint into everything this run
362    /// parked — a refinement of what `ToolCtx` already knew, never a
363    /// replacement for it.
364    ///
365    /// **Merges rather than overwrites, and that is load-bearing.** The park
366    /// seeds fail-closed when the context carries no taint, so an overwrite
367    /// here would let a clean post-run snapshot *downgrade* a question that
368    /// was recorded as unknown-and-therefore-untrusted. Taint only ever grows;
369    /// so does this.
370    ///
371    /// Best-effort by design: a question is already correct at park time, and
372    /// this only sharpens it. That matters because both callers reach this
373    /// line through `?` operators on session writes — an I/O failure must not
374    /// be able to leave a question recorded as cleaner than it was.
375    pub fn stamp_taint(&self, taint: Taint) {
376        for id in self.parked() {
377            let updated = self.store.get(&id).map(|mut q| {
378                q.taint.merge(taint);
379                q
380            });
381            if let Ok(q) = updated {
382                let _lock = self.store.lock();
383                if let Err(e) = self.store.put(&q) {
384                    tracing::warn!(error = %e, id, "could not record taint on a parked question");
385                }
386            }
387        }
388    }
389
390    fn record(
391        &self,
392        question: &str,
393        options: &[String],
394        workspace: Option<PathBuf>,
395        taint: Option<Taint>,
396    ) -> String {
397        // **Unknown taint is untrusted, at the moment of writing.** The stamp
398        // that follows the run is a refinement, and everything between park
399        // and stamp — two `?` on session writes, a kill, a full disk — would
400        // otherwise leave a question asked out of a conversation full of
401        // third-party text recorded as clean, with no warning on `show` and a
402        // zero in `/queues`. Every other unknown in this codebase reads as
403        // untrusted (`distill::corrections_for`, `Session::taint_timeline`);
404        // so does this one.
405        let taint = taint.unwrap_or(Taint {
406            private: true,
407            untrusted: true,
408        });
409        match self.store.park(
410            question,
411            options.to_vec(),
412            &self.session_id,
413            self.task_id.clone(),
414            workspace,
415            taint,
416        ) {
417            Ok(q) => {
418                if let Ok(mut p) = self.parked.lock() {
419                    p.push(q.id.clone());
420                }
421                format!(
422                    "Put to the owner as question {}. This run is ending here — it resumes with \
423                     their answer as the next turn, so there is nothing further to do now. Use \
424                     your last words to say where you got to.",
425                    q.id
426                )
427            }
428            // Fails **open** toward the model, which is the opposite of the
429            // outbox's staging rule and right for the opposite reason. A send
430            // that cannot be staged must not execute, so it fails closed. A
431            // question that cannot be stored is already lost; ending the run
432            // as well would discard the work with no record of why, so the
433            // model is told plainly and left to report.
434            Err(e) => format!(
435                "The question could not be stored ({e:#}), so nobody will see it. Do not wait \
436                 on an answer — carry on if you can, and say what you needed if you cannot."
437            ),
438        }
439    }
440}
441
442#[async_trait::async_trait]
443impl crate::tool::ask::Asker for ParkingAsker {
444    /// The context-free path: no jail to record and no token to cancel with,
445    /// so the question is stored and the run carries on. Reachable only from a
446    /// caller that never routes through `ask_in`, which no front-end here does.
447    async fn ask(&self, question: &str, options: &[String]) -> Option<String> {
448        Some(self.record(question, options, None, None))
449    }
450
451    async fn ask_in(
452        &self,
453        ctx: &crate::tool::ToolCtx,
454        question: &str,
455        options: &[String],
456    ) -> Option<String> {
457        let before = self.parked().len();
458        let answer = self.record(question, options, Some(ctx.workspace.clone()), ctx.taint);
459        // Only stop if it actually landed. A question that failed to store
460        // leaves the run alive to report, per `record`.
461        if self.parked().len() > before {
462            if let Some(cancel) = &ctx.cancel {
463                cancel.cancel();
464            }
465        }
466        Some(answer)
467    }
468}
469
470#[cfg(test)]
471mod tests {
472    use super::*;
473    use crate::tool::ask::Asker;
474    use crate::tool::ToolCtx;
475
476    fn scratch(name: &str) -> PathBuf {
477        let dir = std::env::temp_dir().join(format!(
478            "mecha-questions-test-{name}-{}",
479            std::process::id()
480        ));
481        let _ = std::fs::remove_dir_all(&dir);
482        dir
483    }
484
485    fn store(name: &str) -> QuestionStore {
486        QuestionStore::open(scratch(name)).unwrap()
487    }
488
489    #[test]
490    fn a_parked_question_is_open_and_carries_its_session() {
491        let s = store("park");
492        let q = s
493            .park(
494                "Which address should the letter go to?",
495                vec!["work".into(), "home".into()],
496                "sess-1",
497                Some("task-9".into()),
498                Some(PathBuf::from("/w/a")),
499                Taint::default(),
500            )
501            .unwrap();
502
503        assert!(q.is_open());
504        assert_eq!(q.session_id, "sess-1");
505        assert_eq!(q.task_id.as_deref(), Some("task-9"));
506        assert_eq!(s.open_items().unwrap().len(), 1);
507        assert_eq!(s.get(&q.id).unwrap().options.len(), 2);
508    }
509
510    #[test]
511    fn answering_records_the_words_and_closes_it_once() {
512        let s = store("answer");
513        let q = s
514            .park("Which one?", vec![], "sess-1", None, None, Taint::default())
515            .unwrap();
516
517        let answered = s.answer(&q.id, "the work address").unwrap();
518        assert_eq!(answered.status, "answered");
519        assert_eq!(answered.answer.as_deref(), Some("the work address"));
520        assert!(answered.answered_at.is_some());
521        assert!(s.open_items().unwrap().is_empty());
522
523        // A second answer would resume a conversation that already moved on.
524        assert!(s.answer(&q.id, "no, home").is_err());
525    }
526
527    /// Giving up is a decision about the question, not a reply to it. Writing
528    /// a word into `answer` would put it in the owner's mouth in the
529    /// transcript a resume reads back.
530    #[test]
531    fn abandoning_leaves_the_answer_empty() {
532        let s = store("abandon");
533        let q = s
534            .park("Which one?", vec![], "sess-1", None, None, Taint::default())
535            .unwrap();
536        let done = s.abandon(&q.id).unwrap();
537        assert_eq!(done.status, "abandoned");
538        assert!(done.answer.is_none());
539        assert!(s.open_items().unwrap().is_empty());
540    }
541
542    /// The bug a live run printed: `&id[..8]` of a `Session::new_id` is the
543    /// date, so two questions asked on one day abbreviate identically. Fails
544    /// on the old `short`, which is why it is written against two ids from the
545    /// same day rather than two arbitrary ones.
546    #[test]
547    fn the_short_form_is_the_tail_because_the_head_is_a_date() {
548        let a = "20260826T101804-476080dd";
549        let b = "20260826T134102-91ac33fe";
550        assert_eq!(&a[..8], &b[..8], "the premise: same day, same prefix");
551        assert_eq!(QuestionStore::short(a), "476080dd");
552        assert_ne!(QuestionStore::short(a), QuestionStore::short(b));
553    }
554
555    #[test]
556    fn a_question_is_found_by_its_printed_tail() {
557        let s = store("tail");
558        let q = s
559            .park("a?", vec![], "sess", None, None, Taint::default())
560            .unwrap();
561        let tail = QuestionStore::short(&q.id).to_string();
562        assert_eq!(s.find(&tail).unwrap().id, q.id, "what is printed must work");
563    }
564
565    #[test]
566    fn an_ambiguous_prefix_is_an_error_rather_than_a_guess() {
567        let s = store("find");
568        let a = s
569            .park("a?", vec![], "sess", None, None, Taint::default())
570            .unwrap();
571        assert!(s.find(&a.id).is_ok());
572        assert!(s.find(&a.id[..8]).is_ok());
573        assert!(s.find("nope").is_err());
574
575        // Two records sharing a prefix must not resolve to whichever was read
576        // first — the outbox and sessions both refuse here.
577        let mut twin = a.clone();
578        twin.id = format!("{}zz", a.id);
579        s.put(&twin).unwrap();
580        assert!(s.find(&a.id[..8]).is_err(), "ambiguous prefix must refuse");
581    }
582
583    /// The load-bearing behaviour: asking stops the run instead of waiting in
584    /// it. Fails on the old shape — an `Asker` that blocks leaves the token
585    /// uncancelled and the slot held.
586    #[tokio::test]
587    async fn asking_parks_the_question_and_stops_the_run() {
588        let s = std::sync::Arc::new(store("asker"));
589        let asker = ParkingAsker::new(std::sync::Arc::clone(&s), "sess-7", Some("task-3".into()));
590
591        let cancel = tokio_util::sync::CancellationToken::new();
592        let ctx = ToolCtx {
593            workspace: PathBuf::from("/w/a"),
594            cancel: Some(cancel.clone()),
595            ..Default::default()
596        };
597
598        assert!(!cancel.is_cancelled());
599        let answer = asker.ask_in(&ctx, "Which address?", &[]).await.unwrap();
600
601        assert!(cancel.is_cancelled(), "the run must end, not wait");
602        assert_eq!(asker.parked().len(), 1);
603        let q = s.get(&asker.parked()[0]).unwrap();
604        assert_eq!(q.question, "Which address?");
605        assert_eq!(q.session_id, "sess-7");
606        assert_eq!(q.workspace.as_deref(), Some(Path::new("/w/a")));
607        // The model is told what happened, not handed a decline.
608        assert!(answer.contains(&q.id));
609        assert!(!answer.to_lowercase().contains("declined"));
610    }
611
612    /// A question that could not be stored must not also cost the run. Nobody
613    /// will see it, so ending as well would discard the work with no record of
614    /// why — the opposite of the outbox's fail-closed rule, for the opposite
615    /// reason.
616    #[tokio::test]
617    async fn a_store_that_cannot_write_leaves_the_run_alive() {
618        let dir = scratch("broken");
619        let s = std::sync::Arc::new(QuestionStore::open(&dir).unwrap());
620        std::fs::remove_dir_all(&dir).unwrap();
621
622        let asker = ParkingAsker::new(s, "sess-7", None);
623        let cancel = tokio_util::sync::CancellationToken::new();
624        let ctx = ToolCtx {
625            cancel: Some(cancel.clone()),
626            ..Default::default()
627        };
628
629        let answer = asker.ask_in(&ctx, "Which address?", &[]).await.unwrap();
630        assert!(
631            !cancel.is_cancelled(),
632            "a lost question must not end the run"
633        );
634        assert!(asker.parked().is_empty());
635        assert!(answer.contains("could not be stored"));
636    }
637
638    /// Taint is recorded **at park time** from the context, so nothing that
639    /// happens between the question and the end of the run can leave it
640    /// looking clean. The later stamp only sharpens it.
641    #[tokio::test]
642    async fn taint_is_recorded_when_the_question_is_parked() {
643        let s = std::sync::Arc::new(store("taint"));
644        let asker = ParkingAsker::new(std::sync::Arc::clone(&s), "sess-7", None);
645        let ctx = ToolCtx {
646            taint: Some(Taint {
647                private: true,
648                untrusted: true,
649            }),
650            ..Default::default()
651        };
652        asker.ask_in(&ctx, "Which address?", &[]).await.unwrap();
653
654        let q = s.get(&asker.parked()[0]).unwrap();
655        assert!(
656            q.taint.private && q.taint.untrusted,
657            "the warning must not depend on a stamp that may never run"
658        );
659    }
660
661    /// **Unknown taint is untrusted.** A context with no snapshot is not
662    /// evidence of a clean conversation, and every other unknown in this
663    /// codebase reads the same way. Fails on the old behaviour, which
664    /// defaulted the field and left the question recorded as clean until a
665    /// post-run stamp that two `?` operators could skip.
666    #[tokio::test]
667    async fn a_context_with_no_taint_parks_as_untrusted() {
668        let s = std::sync::Arc::new(store("taint-unknown"));
669        let asker = ParkingAsker::new(std::sync::Arc::clone(&s), "sess-7", None);
670        asker
671            .ask_in(&ToolCtx::default(), "Which address?", &[])
672            .await
673            .unwrap();
674
675        let q = s.get(&asker.parked()[0]).unwrap();
676        assert!(q.taint.untrusted, "unknown must not read as clean");
677    }
678
679    /// The stamp merges and never replaces. Overwriting would let a clean
680    /// post-run snapshot downgrade a question parked as unknown — taint only
681    /// grows, and so must this.
682    #[tokio::test]
683    async fn the_stamp_can_only_add_taint_never_remove_it() {
684        let s = std::sync::Arc::new(store("taint-merge"));
685        let asker = ParkingAsker::new(std::sync::Arc::clone(&s), "sess-7", None);
686        asker
687            .ask_in(&ToolCtx::default(), "Which address?", &[])
688            .await
689            .unwrap();
690        let id = asker.parked()[0].clone();
691
692        asker.stamp_taint(Taint::default());
693        assert!(
694            s.get(&id).unwrap().taint.untrusted,
695            "a clean stamp must not launder an unknown park"
696        );
697
698        // And it does still sharpen a known-clean one upward.
699        let s2 = std::sync::Arc::new(store("taint-merge-2"));
700        let a2 = ParkingAsker::new(std::sync::Arc::clone(&s2), "sess-8", None);
701        let clean = ToolCtx {
702            taint: Some(Taint::default()),
703            ..Default::default()
704        };
705        a2.ask_in(&clean, "Which?", &[]).await.unwrap();
706        let id2 = a2.parked()[0].clone();
707        assert!(!s2.get(&id2).unwrap().taint.untrusted);
708        a2.stamp_taint(Taint {
709            private: false,
710            untrusted: true,
711        });
712        assert!(s2.get(&id2).unwrap().taint.untrusted, "growth still lands");
713    }
714}