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}