1use std::fmt;
2use std::path::Path;
3use std::thread;
4use std::time::{Duration, Instant};
5
6use serde::{Deserialize, Serialize};
7use serde_json::{Value, json};
8use thiserror::Error;
9
10use crate::workspace::Workspace;
11
12#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
13#[serde(rename_all = "snake_case")]
14pub enum FalseGreenVerdict {
15 Accepted,
16 Failed,
17 Incomplete,
18 InsufficientEvidence,
19 Invalid,
20}
21
22#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
25pub struct CompletionAuthority {
26 #[serde(default)]
27 pub authority_ready: bool,
28 #[serde(default)]
29 pub may_claim_complete: bool,
30}
31
32impl CompletionAuthority {
33 #[must_use]
34 pub const fn permits_completion(self) -> bool {
35 self.authority_ready && self.may_claim_complete
36 }
37}
38
39#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
40pub struct FalseGreenResult {
41 #[serde(alias = "verdict")]
42 pub verification: FalseGreenVerdict,
43 #[serde(alias = "canonical_status")]
44 pub verification_status: String,
45 #[serde(default)]
46 pub completion_authority: CompletionAuthority,
47 pub repairable: bool,
48 pub candidate_sha256: String,
49 pub authoritative_source_sha256: Option<String>,
50 pub run_id: Option<String>,
51 pub evidence: Value,
52}
53
54impl FalseGreenResult {
55 #[must_use]
56 pub fn accepted(candidate_sha256: impl Into<String>) -> Self {
57 Self {
58 verification: FalseGreenVerdict::Accepted,
59 verification_status: "accepted".to_owned(),
60 completion_authority: CompletionAuthority {
61 authority_ready: true,
62 may_claim_complete: true,
63 },
64 repairable: false,
65 candidate_sha256: candidate_sha256.into(),
66 authoritative_source_sha256: None,
67 run_id: None,
68 evidence: json!({"status": "accepted"}),
69 }
70 }
71
72 #[must_use]
73 pub fn accepted_awaiting_authority(candidate_sha256: impl Into<String>) -> Self {
74 Self {
75 verification: FalseGreenVerdict::Accepted,
76 verification_status: "accepted".to_owned(),
77 completion_authority: CompletionAuthority::default(),
78 repairable: false,
79 candidate_sha256: candidate_sha256.into(),
80 authoritative_source_sha256: None,
81 run_id: None,
82 evidence: json!({"status": "accepted"}),
83 }
84 }
85
86 #[must_use]
87 pub fn failed(candidate_sha256: impl Into<String>, evidence: Value) -> Self {
88 Self {
89 verification: FalseGreenVerdict::Failed,
90 verification_status: "failed".to_owned(),
91 completion_authority: CompletionAuthority::default(),
92 repairable: true,
93 candidate_sha256: candidate_sha256.into(),
94 authoritative_source_sha256: None,
95 run_id: None,
96 evidence,
97 }
98 }
99
100 #[must_use]
101 pub fn permits_completion(&self) -> bool {
102 self.verification == FalseGreenVerdict::Accepted
103 && self.completion_authority.permits_completion()
104 }
105}
106
107#[derive(Debug, Error)]
108pub enum FalseGreenError {
109 #[error(transparent)]
110 Client(#[from] falsegreen_core::ClientError),
111 #[error("canonical FalseGreen result has no string status")]
112 MissingStatus,
113 #[error("canonical FalseGreen pending result has no run identity")]
114 MissingRunIdentity,
115 #[error("canonical FalseGreen pending run identity changed")]
116 RunIdentityChanged,
117 #[error("canonical FalseGreen verification timed out")]
118 Timeout,
119 #[error("canonical FalseGreen repair evidence is unavailable")]
120 MissingRepairEvidence,
121}
122
123pub trait FalseGreenVerifier {
124 fn verify(
125 &mut self,
126 workspace: &Workspace,
127 candidate_sha256: &str,
128 ) -> Result<FalseGreenResult, FalseGreenError>;
129
130 fn prepare_repair(&mut self, _workspace: &Workspace) -> Result<Value, FalseGreenError> {
131 Ok(Value::Null)
132 }
133}
134
135trait CanonicalClient {
136 fn check_completion(
137 &self,
138 workspace_root: &Path,
139 task_id: &str,
140 ) -> Result<Value, FalseGreenError>;
141
142 fn authority_status(&self, task_id: &str) -> Result<Value, FalseGreenError>;
143}
144
145impl CanonicalClient for falsegreen_core::Client {
146 fn check_completion(
147 &self,
148 workspace_root: &Path,
149 task_id: &str,
150 ) -> Result<Value, FalseGreenError> {
151 Ok(self
152 .check_completion(workspace_root, task_id)?
153 .into_payload())
154 }
155
156 fn authority_status(&self, task_id: &str) -> Result<Value, FalseGreenError> {
157 Ok(self.authority_status(task_id)?.into_payload())
158 }
159}
160
161pub struct EmbeddedFalseGreenVerifier {
167 client: Box<dyn CanonicalClient>,
168 task_id: String,
169 verification_timeout: Duration,
170 poll_interval: Duration,
171 repair_evidence: Option<Value>,
172}
173
174impl EmbeddedFalseGreenVerifier {
175 pub fn from_stored_session(
178 task_id: impl Into<String>,
179 verification_timeout: Duration,
180 ) -> Result<Self, FalseGreenError> {
181 Ok(Self {
182 client: Box::new(falsegreen_core::Client::from_stored_session()?),
183 task_id: task_id.into(),
184 verification_timeout,
185 poll_interval: Duration::from_millis(250),
186 repair_evidence: None,
187 })
188 }
189
190 #[cfg(test)]
191 fn with_client(task_id: impl Into<String>, client: impl CanonicalClient + 'static) -> Self {
192 Self {
193 client: Box::new(client),
194 task_id: task_id.into(),
195 verification_timeout: Duration::from_secs(1),
196 poll_interval: Duration::ZERO,
197 repair_evidence: None,
198 }
199 }
200
201 fn completion_decision(&self, workspace_root: &Path) -> Result<Value, FalseGreenError> {
202 let started = Instant::now();
203 let mut pending_run_id: Option<String> = None;
204 loop {
205 let decision = self
206 .client
207 .check_completion(workspace_root, &self.task_id)?;
208 let status = decision
209 .get("status")
210 .and_then(Value::as_str)
211 .ok_or(FalseGreenError::MissingStatus)?;
212 if !pending_status(status) {
213 return Ok(decision);
214 }
215
216 let run_id = decision
217 .get("run_id")
218 .and_then(Value::as_str)
219 .ok_or(FalseGreenError::MissingRunIdentity)?;
220 if pending_run_id
221 .as_deref()
222 .is_some_and(|expected| expected != run_id)
223 {
224 return Err(FalseGreenError::RunIdentityChanged);
225 }
226 pending_run_id.get_or_insert_with(|| run_id.to_owned());
227
228 let elapsed = started.elapsed();
229 if elapsed >= self.verification_timeout {
230 return Err(FalseGreenError::Timeout);
231 }
232 thread::sleep(
233 self.poll_interval
234 .min(self.verification_timeout.saturating_sub(elapsed)),
235 );
236 }
237 }
238}
239
240impl fmt::Debug for EmbeddedFalseGreenVerifier {
241 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
242 formatter
243 .debug_struct("EmbeddedFalseGreenVerifier")
244 .field("task_id", &self.task_id)
245 .field("verification_timeout", &self.verification_timeout)
246 .field("repair_evidence", &self.repair_evidence)
247 .finish_non_exhaustive()
248 }
249}
250
251impl FalseGreenVerifier for EmbeddedFalseGreenVerifier {
252 fn verify(
253 &mut self,
254 workspace: &Workspace,
255 candidate_sha256: &str,
256 ) -> Result<FalseGreenResult, FalseGreenError> {
257 let decision = self.completion_decision(workspace.root())?;
258 let status = decision
259 .get("status")
260 .and_then(Value::as_str)
261 .ok_or(FalseGreenError::MissingStatus)?
262 .to_owned();
263 let authority = self.client.authority_status(&self.task_id)?;
264 let repairable = explicit_true(&decision, "repair_authorized");
265 self.repair_evidence = repairable.then(|| decision.clone());
266
267 let verification = match status.as_str() {
268 "accepted" => FalseGreenVerdict::Accepted,
269 "failed" | "unsafe" => FalseGreenVerdict::Failed,
270 "incomplete" => FalseGreenVerdict::Incomplete,
271 "insufficient_evidence" => FalseGreenVerdict::InsufficientEvidence,
272 _ => FalseGreenVerdict::Invalid,
273 };
274 let completion_authority = CompletionAuthority {
275 authority_ready: explicit_true(&authority, "authority_ready"),
276 may_claim_complete: explicit_true(&authority, "may_claim_complete"),
277 };
278 Ok(FalseGreenResult {
279 verification,
280 verification_status: status,
281 completion_authority,
282 repairable,
283 candidate_sha256: candidate_sha256.to_owned(),
284 authoritative_source_sha256: source_sha256(&decision),
285 run_id: decision
286 .get("run_id")
287 .and_then(Value::as_str)
288 .map(str::to_owned),
289 evidence: json!({
290 "completion_decision": decision,
291 "authority_status": authority
292 }),
293 })
294 }
295
296 fn prepare_repair(&mut self, _workspace: &Workspace) -> Result<Value, FalseGreenError> {
297 self.repair_evidence
298 .take()
299 .ok_or(FalseGreenError::MissingRepairEvidence)
300 }
301}
302
303fn explicit_true(payload: &Value, name: &str) -> bool {
304 payload.get(name).and_then(Value::as_bool) == Some(true)
305}
306
307fn pending_status(status: &str) -> bool {
308 matches!(
309 status,
310 "QUEUED" | "CLAIMED" | "EXECUTING" | "RUNNING" | "queued" | "running"
311 )
312}
313
314fn source_sha256(payload: &Value) -> Option<String> {
315 payload
316 .get("source_sha256")
317 .or_else(|| payload.get("source_artifact_sha256"))
318 .and_then(Value::as_str)
319 .map(str::to_owned)
320}
321
322#[cfg(test)]
323mod tests {
324 use std::cell::RefCell;
325 use std::collections::VecDeque;
326 use std::path::Path;
327 use std::rc::Rc;
328
329 use serde_json::{Value, json};
330
331 use crate::workspace::Workspace;
332 use crate::workspace::tests::git_fixture;
333
334 use super::{
335 CanonicalClient, CompletionAuthority, EmbeddedFalseGreenVerifier, FalseGreenError,
336 FalseGreenVerdict, FalseGreenVerifier,
337 };
338
339 struct ScriptedClient {
340 checks: RefCell<VecDeque<Value>>,
341 statuses: RefCell<VecDeque<Value>>,
342 calls: Rc<RefCell<Vec<String>>>,
343 }
344
345 impl ScriptedClient {
346 fn new(check: Value, status: Value) -> (Self, Rc<RefCell<Vec<String>>>) {
347 Self::with_checks(vec![check], status)
348 }
349
350 fn with_checks(checks: Vec<Value>, status: Value) -> (Self, Rc<RefCell<Vec<String>>>) {
351 let calls = Rc::new(RefCell::new(Vec::new()));
352 (
353 Self {
354 checks: RefCell::new(VecDeque::from(checks)),
355 statuses: RefCell::new(VecDeque::from([status])),
356 calls: Rc::clone(&calls),
357 },
358 calls,
359 )
360 }
361 }
362
363 impl CanonicalClient for ScriptedClient {
364 fn check_completion(
365 &self,
366 workspace_root: &Path,
367 task_id: &str,
368 ) -> Result<Value, FalseGreenError> {
369 assert!(workspace_root.is_dir());
370 self.calls.borrow_mut().push(format!("check:{task_id}"));
371 Ok(self.checks.borrow_mut().pop_front().expect("check result"))
372 }
373
374 fn authority_status(&self, task_id: &str) -> Result<Value, FalseGreenError> {
375 self.calls.borrow_mut().push(format!("status:{task_id}"));
376 Ok(self
377 .statuses
378 .borrow_mut()
379 .pop_front()
380 .expect("status result"))
381 }
382 }
383
384 fn verify_shape(
385 decision: Value,
386 authority: Value,
387 ) -> (super::FalseGreenResult, EmbeddedFalseGreenVerifier) {
388 let directory = git_fixture();
389 let workspace = Workspace::open(directory.path()).expect("workspace");
390 let (client, _) = ScriptedClient::new(decision, authority);
391 let mut verifier = EmbeddedFalseGreenVerifier::with_client("task_1", client);
392 let result = verifier.verify(&workspace, "candidate").expect("verify");
393 (result, verifier)
394 }
395
396 #[test]
397 fn canonical_check_and_status_are_called_in_process() {
398 let directory = git_fixture();
399 let workspace = Workspace::open(directory.path()).expect("workspace");
400 let (client, calls) = ScriptedClient::new(
401 json!({"task_id": "task_1", "status": "accepted"}),
402 json!({
403 "task_id": "task_1",
404 "status": "accepted",
405 "authority_ready": true,
406 "may_claim_complete": true
407 }),
408 );
409 let mut verifier = EmbeddedFalseGreenVerifier::with_client("task_1", client);
410
411 let result = verifier.verify(&workspace, "candidate").expect("verify");
412
413 assert_eq!(&*calls.borrow(), &["check:task_1", "status:task_1"]);
414 assert!(result.permits_completion());
415 }
416
417 #[test]
418 fn hosted_pending_run_is_polled_without_changing_run_identity() {
419 let directory = git_fixture();
420 let workspace = Workspace::open(directory.path()).expect("workspace");
421 let (client, calls) = ScriptedClient::with_checks(
422 vec![
423 json!({"task_id": "task_1", "status": "QUEUED", "run_id": "run_1"}),
424 json!({"task_id": "task_1", "status": "EXECUTING", "run_id": "run_1"}),
425 json!({"task_id": "task_1", "status": "accepted", "run_id": "result_1"}),
426 ],
427 json!({
428 "task_id": "task_1",
429 "status": "accepted",
430 "authority_ready": true,
431 "may_claim_complete": true
432 }),
433 );
434 let mut verifier = EmbeddedFalseGreenVerifier::with_client("task_1", client);
435
436 let result = verifier.verify(&workspace, "candidate").expect("verify");
437
438 assert_eq!(
439 &*calls.borrow(),
440 &[
441 "check:task_1",
442 "check:task_1",
443 "check:task_1",
444 "status:task_1"
445 ]
446 );
447 assert!(result.permits_completion());
448 assert_eq!(result.run_id.as_deref(), Some("result_1"));
449 }
450
451 #[test]
452 fn hosted_pending_run_identity_cannot_change_during_polling() {
453 let directory = git_fixture();
454 let workspace = Workspace::open(directory.path()).expect("workspace");
455 let (client, _) = ScriptedClient::with_checks(
456 vec![
457 json!({"task_id": "task_1", "status": "QUEUED", "run_id": "run_1"}),
458 json!({"task_id": "task_1", "status": "EXECUTING", "run_id": "run_2"}),
459 ],
460 json!({"task_id": "task_1", "status": "accepted"}),
461 );
462 let mut verifier = EmbeddedFalseGreenVerifier::with_client("task_1", client);
463
464 assert!(matches!(
465 verifier.verify(&workspace, "candidate"),
466 Err(FalseGreenError::RunIdentityChanged)
467 ));
468 }
469
470 #[test]
471 fn accepted_verification_remains_accepted_while_awaiting_authority() {
472 let (result, _) = verify_shape(
473 json!({"task_id": "task_1", "status": "accepted"}),
474 json!({
475 "task_id": "task_1",
476 "status": "accepted",
477 "authority_ready": false,
478 "may_claim_complete": false
479 }),
480 );
481 assert_eq!(result.verification, FalseGreenVerdict::Accepted);
482 assert_eq!(result.verification_status, "accepted");
483 assert_eq!(result.completion_authority, CompletionAuthority::default());
484 assert!(!result.permits_completion());
485 }
486
487 #[test]
488 fn authority_matrix_requires_both_flags_and_accepted_verification() {
489 for authority_ready in [false, true] {
490 for may_claim_complete in [false, true] {
491 let (result, _) = verify_shape(
492 json!({"task_id": "task_1", "status": "accepted"}),
493 json!({
494 "task_id": "task_1",
495 "status": "accepted",
496 "authority_ready": authority_ready,
497 "may_claim_complete": may_claim_complete
498 }),
499 );
500 assert_eq!(
501 result.permits_completion(),
502 authority_ready && may_claim_complete
503 );
504 }
505 }
506 let (failed, _) = verify_shape(
507 json!({"task_id": "task_1", "status": "failed"}),
508 json!({
509 "task_id": "task_1",
510 "status": "failed",
511 "authority_ready": true,
512 "may_claim_complete": true
513 }),
514 );
515 assert_eq!(failed.verification, FalseGreenVerdict::Failed);
516 assert!(!failed.permits_completion());
517 }
518
519 #[test]
520 fn only_core_repair_authorization_opens_repair_path_without_double_consumption() {
521 let decision = json!({
522 "task_id": "task_1",
523 "status": "incomplete",
524 "repair_authorized": true,
525 "repair_cycles_remaining": 1,
526 "failures": [{"criterion": "AC-1", "summary": "failed"}]
527 });
528 let (result, mut verifier) = verify_shape(
529 decision.clone(),
530 json!({"task_id": "task_1", "status": "incomplete"}),
531 );
532 assert!(result.repairable);
533 let directory = git_fixture();
534 let workspace = Workspace::open(directory.path()).expect("workspace");
535 assert_eq!(verifier.prepare_repair(&workspace).unwrap(), decision);
536 assert!(matches!(
537 verifier.prepare_repair(&workspace),
538 Err(FalseGreenError::MissingRepairEvidence)
539 ));
540 }
541
542 #[test]
543 fn local_status_and_remaining_budget_cannot_infer_repair_authority() {
544 let (result, mut verifier) = verify_shape(
545 json!({
546 "task_id": "task_1",
547 "status": "incomplete",
548 "repair_cycles_remaining": 2
549 }),
550 json!({"task_id": "task_1", "status": "incomplete"}),
551 );
552 assert!(!result.repairable);
553 let directory = git_fixture();
554 let workspace = Workspace::open(directory.path()).expect("workspace");
555 assert!(matches!(
556 verifier.prepare_repair(&workspace),
557 Err(FalseGreenError::MissingRepairEvidence)
558 ));
559 }
560
561 #[test]
562 fn unknown_status_is_invalid_even_with_authority_flags() {
563 let (result, _) = verify_shape(
564 json!({"task_id": "task_1", "status": "model_says_done"}),
565 json!({
566 "task_id": "task_1",
567 "authority_ready": true,
568 "may_claim_complete": true
569 }),
570 );
571 assert_eq!(result.verification, FalseGreenVerdict::Invalid);
572 assert!(!result.permits_completion());
573 }
574}