1use crate::canonical::compute_id;
2use crate::store::Store;
3use crate::tick::{Check, Ground, Liveness, Tick};
4use crate::verify::verify;
5use std::path::Path;
6use std::process::ExitCode;
7
8fn triggered_since(
12 repo: &std::path::Path,
13 ground: &crate::tick::Ground,
14 receipts: &[crate::receipt::Receipt],
15) -> bool {
16 use crate::tick::Check;
17 let triggered_by = match &ground.check {
18 Some(Check::Test { liveness, .. }) => &liveness.triggered_by,
19 _ => return false,
20 };
21 let latest = receipts.iter().max_by(|a, b| a.ran_at.cmp(&b.ran_at));
22 match latest {
23 Some(r) => crate::liveness::changed_since(repo, &r.commit, triggered_by).unwrap_or(false),
24 None => false,
25 }
26}
27
28pub fn init(repo: &Path) -> ExitCode {
29 let store = Store::at(repo);
30 match store.init() {
31 Ok(true) => {
32 println!("created .evolving/ (content-addressed chain + results cache)");
33 ExitCode::SUCCESS
34 }
35 Ok(false) => {
36 println!(".evolving/ already exists (no-op)");
37 ExitCode::SUCCESS
38 }
39 Err(e) => {
40 eprintln!("error: could not create .evolving/: {e}");
41 ExitCode::FAILURE
42 }
43 }
44}
45pub fn show(repo: &Path, id: &str) -> ExitCode {
46 let store = Store::at(repo);
47 let path = store.ticks_dir().join(id);
48 if !path.is_file() {
49 eprintln!("error: no tick with id {id}");
50 return ExitCode::FAILURE;
51 }
52 match std::fs::read_to_string(&path) {
53 Ok(text) => {
54 println!("{text}");
56 if let Ok(v) = serde_json::from_str::<serde_json::Value>(&text) {
58 if let Some(a) = v.get("authority").and_then(|x| x.as_str()) {
59 println!("authority: {a}");
60 }
61 }
62 ExitCode::SUCCESS
63 }
64 Err(e) => {
65 eprintln!("error: reading {id}: {e}");
66 ExitCode::FAILURE
67 }
68 }
69}
70pub fn decide(repo: &Path, decision: Option<&str>, args: &[String]) -> ExitCode {
71 let (decision, args): (Option<&str>, Vec<String>) = match decision {
76 Some(d) if d.starts_with('-') => {
77 let mut v = vec![d.to_string()];
78 v.extend_from_slice(args);
79 (None, v)
80 }
81 other => (other, args.to_vec()),
82 };
83 match crate::capture::run(repo, decision, &args) {
84 Ok(t) => {
85 crate::events::append(&Store::at(repo), "decide", Some(&t.id), None);
86 println!("recorded {} ({} ground(s))", t.id, t.grounds.len());
87 ExitCode::SUCCESS
88 }
89 Err(e) => {
90 eprintln!("error: {e}");
91 ExitCode::FAILURE
92 }
93 }
94}
95
96pub fn guard(repo: &Path, a: crate::guard::GuardArgs) -> ExitCode {
97 match crate::guard::run(repo, a) {
98 Ok(t) => {
99 crate::events::append(&Store::at(repo), "guard", Some(&t.id), None);
100 println!("bound; wrote child {}", t.id);
101 ExitCode::SUCCESS
102 }
103 Err(e) => {
104 eprintln!("error: {e}");
105 ExitCode::FAILURE
106 }
107 }
108}
109
110pub fn verify_cmd(repo: &Path, self_test: bool) -> ExitCode {
111 if self_test {
112 return self_test_golden();
113 }
114 let store = Store::at(repo);
115 match verify(&store) {
116 Ok(v) if v.is_empty() => {
117 println!("✓ chain intact: every id == hash(payload), lineage forward-only");
118 println!("✓ every tick validates against the closed schema (R1) and check shape (R2)");
119 ExitCode::SUCCESS
120 }
121 Ok(v) => {
122 for line in &v {
123 println!("✗ {line}");
124 }
125 eprintln!("{} violation(s)", v.len());
126 ExitCode::FAILURE
127 }
128 Err(e) => {
129 eprintln!("error: reading store: {e}");
130 ExitCode::FAILURE
131 }
132 }
133}
134
135fn latest_ran_at(receipts: &[crate::receipt::Receipt]) -> Option<String> {
138 receipts.iter().map(|r| r.ran_at.clone()).max()
139}
140
141fn live_ctx(
145 store: &Store,
146 staleness_days: u64,
147 live_origin_sha: Option<String>,
148 attest: Option<Vec<String>>,
149) -> crate::verdict::Ctx {
150 crate::verdict::Ctx {
151 live_origin_sha,
152 selected: crate::selected::read(store).unwrap_or(None),
153 now_unix: time::OffsetDateTime::now_utc().unix_timestamp(),
154 staleness_secs: staleness_days as i64 * 86_400,
155 attest,
156 }
157}
158
159pub fn check(
160 repo: &Path,
161 exit_on_red: bool,
162 run: bool,
163 platform: &str,
164 offline: bool,
165 attest: Vec<String>,
166) -> ExitCode {
167 use crate::verdict::{verdict_for, Verdict};
168 let store = Store::at(repo);
169 if !store.exists() {
170 eprintln!("error: no .evolving/ store here — run `ev init` first");
171 return ExitCode::FAILURE;
172 }
173 let files = match store.read_all() {
174 Ok(f) => f,
175 Err(e) => {
176 eprintln!("error: reading store: {e}");
177 return ExitCode::FAILURE;
178 }
179 };
180 let config = crate::config::read(&store);
181
182 if run {
185 for (_filename, raw) in &files {
186 let t = match crate::tick::from_value(raw) {
187 Ok(t) => t,
188 Err(_) => continue,
189 };
190 if t.status != "live" {
191 continue;
192 }
193 for g in &t.grounds {
194 if let Some(Check::Test {
195 reference,
196 counter_test,
197 liveness,
198 ..
199 }) = &g.check
200 {
201 if liveness.platforms.iter().any(|p| p == platform) {
202 match crate::runner::run_check(
204 repo,
205 reference,
206 platform,
207 config.green_exit_code,
208 ) {
209 Ok(mut rc) => {
210 if let Ok(ct) = crate::runner::run_check(
212 repo,
213 counter_test,
214 platform,
215 config.green_exit_code,
216 ) {
217 rc.falsifiable = Some(rc.result != ct.result);
218 }
219 if let Err(e) = crate::receipt::append(&store, &rc) {
220 eprintln!(
221 "warning: could not write receipt for {reference:?}: {e}"
222 );
223 }
224 }
225 Err(e) => eprintln!("warning: run failed for {reference:?}: {e}"),
226 }
227 }
228 }
229 }
230 }
231 }
232
233 let live_origin = crate::staleness::resolve(repo, &store, &config.staleness_ref, offline);
234 let attest = if attest.is_empty() {
235 None
236 } else {
237 Some(attest)
238 };
239 let ctx = live_ctx(&store, config.staleness_days, live_origin, attest);
240 let mut rows: Vec<String> = Vec::new();
241 let mut any_not_green = false;
242
243 for (filename, raw) in &files {
244 let t = match crate::tick::from_value(raw) {
245 Ok(t) => t,
246 Err(_) => continue, };
248 if t.status != "live" {
249 continue;
250 }
251 let mut verdicts = Vec::with_capacity(t.grounds.len());
252 for g in &t.grounds {
253 let receipts = match &g.check {
255 Some(Check::Test { reference, .. }) => {
256 crate::receipt::read_for(&store, reference).unwrap_or_default()
257 }
258 _ => Vec::new(),
259 };
260 let ts = triggered_since(repo, g, &receipts);
262 let v = verdict_for(g, &receipts, &ctx, ts);
263 if !matches!(v, Verdict::Green | Verdict::NotApplicable | Verdict::Exempt) {
264 any_not_green = true;
265 }
266 if matches!(&g.check, Some(Check::Test { .. })) {
268 let detail = match &v {
269 Verdict::NotRun { missing_platforms } => {
270 format!("missing: {}", missing_platforms.join(", "))
271 }
272 Verdict::Stale { reason } => reason.clone(),
273 _ => latest_ran_at(&receipts)
274 .map(|ts| format!("ran {ts}"))
275 .unwrap_or_else(|| "no receipt".into()),
276 };
277 rows.push(format!(
278 "{}\t{filename}\t{:?}\t({detail})",
279 v.label(),
280 g.claim
281 ));
282 crate::events::append(&store, "check", Some(&t.id), Some(v.label()));
283 }
284 verdicts.push((g, v));
285 }
286 let _ = crate::state::write_state(
288 &store,
289 &t.id,
290 &verdicts,
291 &config.staleness_ref,
292 ctx.live_origin_sha.as_deref(),
293 );
294 }
295
296 if rows.is_empty() {
297 println!("no test-bound grounds to check");
298 } else {
299 for r in &rows {
300 println!("{r}");
301 }
302 if !run {
303 println!("note: run `ev check --run` to execute each counter-test and prove its falsifiability");
307 }
308 }
309 if exit_on_red && any_not_green {
310 return ExitCode::FAILURE;
311 }
312 ExitCode::SUCCESS
313}
314
315pub fn why(repo: &Path, selector: &str) -> ExitCode {
316 let store = Store::at(repo);
317 if !store.exists() {
318 eprintln!("error: no .evolving/ store here — run `ev init` first");
319 return ExitCode::FAILURE;
320 }
321 let files = match store.read_all() {
322 Ok(f) => f,
323 Err(e) => {
324 eprintln!("error: reading store: {e}");
325 return ExitCode::FAILURE;
326 }
327 };
328 let mut found = false;
329 for (filename, raw) in &files {
330 let t = match crate::tick::from_value(raw) {
331 Ok(t) => t,
332 Err(_) => continue,
333 };
334 if t.status != "live" {
335 continue;
336 }
337 for g in &t.grounds {
338 if let Some(Check::Test { reference, .. }) = &g.check {
339 if reference.as_str() == selector {
340 found = true;
341 println!(
342 "{filename}\t{:?}\tguards: {:?} ({})",
343 t.decision, g.claim, g.supports
344 );
345 }
346 }
347 }
348 }
349 if !found {
350 eprintln!("{selector:?} guards nothing");
351 return ExitCode::FAILURE;
352 }
353 ExitCode::SUCCESS
354}
355
356pub fn list(repo: &Path) -> ExitCode {
358 let store = Store::at(repo);
359 if !store.exists() {
360 eprintln!("error: no .evolving/ store here — run `ev init` first");
361 return ExitCode::FAILURE;
362 }
363 let files = match store.read_all() {
364 Ok(f) => f,
365 Err(e) => {
366 eprintln!("error: reading store: {e}");
367 return ExitCode::FAILURE;
368 }
369 };
370 let mut rows: Vec<(String, String, String, Option<String>)> = files
371 .iter()
372 .map(|(name, raw)| match crate::tick::from_value(raw) {
373 Ok(t) => (name.clone(), t.status, t.decision, t.authority),
374 Err(_) => (name.clone(), "?".into(), "<unparseable>".into(), None),
375 })
376 .collect();
377 rows.sort();
378 if rows.is_empty() {
379 println!("no decisions yet");
380 return ExitCode::SUCCESS;
381 }
382 for (id, status, decision, authority) in &rows {
383 match authority {
384 Some(a) => println!("{id}\t{status}\t{decision:?}\tauthority={a}"),
385 None => println!("{id}\t{status}\t{decision:?}"),
386 }
387 }
388 ExitCode::SUCCESS
389}
390
391pub fn brief(repo: &Path, limit: Option<usize>) -> ExitCode {
396 let store = Store::at(repo);
397 if !store.exists() {
398 eprintln!("error: no .evolving/ store here — run `ev init` first");
399 return ExitCode::FAILURE;
400 }
401 let files = match store.read_all() {
402 Ok(f) => f,
403 Err(e) => {
404 eprintln!("error: reading store: {e}");
405 return ExitCode::FAILURE;
406 }
407 };
408 let limit = limit.unwrap_or(crate::config::read(&store).brief_limit);
410 let mut kept: Vec<(String, Tick)> = files
412 .iter()
413 .filter_map(|(name, raw)| crate::tick::from_value(raw).ok().map(|t| (name.clone(), t)))
414 .filter(|(_, t)| t.status == "live" && t.authority.as_deref() == Some("user-ruled"))
415 .collect();
416 kept.sort_by(|a, b| b.1.held_since.cmp(&a.1.held_since).then(b.0.cmp(&a.0)));
418 if kept.is_empty() {
419 println!("no user-ruled decisions");
420 return ExitCode::SUCCESS;
421 }
422 let total = kept.len();
423 if limit > 0 {
424 kept.truncate(limit);
425 }
426 for (_id, t) in &kept {
427 println!("{} [user-ruled]", t.decision);
428 for g in &t.grounds {
429 if let Some(option) = g.supports.strip_prefix("rejected:") {
430 println!(" rejected {option}: {}", g.claim);
431 }
432 }
433 }
434 if total > kept.len() {
435 println!(
436 "… {} more user-ruled decision(s) — `ev list` for all",
437 total - kept.len()
438 );
439 }
440 ExitCode::SUCCESS
441}
442
443pub fn log(repo: &Path) -> ExitCode {
445 let store = Store::at(repo);
446 if !store.exists() {
447 eprintln!("error: no .evolving/ store here — run `ev init` first");
448 return ExitCode::FAILURE;
449 }
450 let mut id = match store.read_head() {
451 Ok(h) => h,
452 Err(e) => {
453 eprintln!("error: reading HEAD: {e}");
454 return ExitCode::FAILURE;
455 }
456 };
457 if id.is_empty() {
458 println!("no decisions yet");
459 return ExitCode::SUCCESS;
460 }
461 let mut seen = std::collections::HashSet::new();
462 while !id.is_empty() {
463 if !seen.insert(id.clone()) {
464 break; }
466 match store.read_tick(&id) {
467 Ok(Some(t)) => {
468 println!("{}\t{}\t{:?}", t.id, t.status, t.decision);
469 id = t.parent_id;
470 }
471 Ok(None) => {
472 eprintln!("warning: {id} not found (broken lineage)");
473 break;
474 }
475 Err(e) => {
476 eprintln!("error: reading {id}: {e}");
477 return ExitCode::FAILURE;
478 }
479 }
480 }
481 ExitCode::SUCCESS
482}
483
484pub fn reopen(repo: &Path, id: &str) -> ExitCode {
485 let store = Store::at(repo);
486 let tick = match store.read_tick(id) {
487 Ok(Some(t)) => t,
488 Ok(None) => {
489 eprintln!("error: no tick with id {id}");
490 return ExitCode::FAILURE;
491 }
492 Err(e) => {
493 eprintln!("error: reading {id}: {e}");
494 return ExitCode::FAILURE;
495 }
496 };
497 let config = crate::config::read(&store);
498 let live_origin = crate::staleness::resolve(repo, &store, &config.staleness_ref, true);
499 let ctx = live_ctx(&store, config.staleness_days, live_origin, None);
500
501 crate::events::append(&store, "reopen", Some(id), None);
502 println!("decision {}: {:?}", tick.id, tick.decision);
503 if !tick.observe.is_empty() {
504 println!("observe: {:?}", tick.observe);
505 }
506 if let Some(a) = &tick.authority {
507 println!("authority: {a}");
508 }
509 for g in &tick.grounds {
510 match &g.check {
511 Some(Check::Test {
512 reference,
513 verified_at_sha,
514 ..
515 }) => {
516 let receipts = crate::receipt::read_for(&store, reference).unwrap_or_default();
517 let ts = triggered_since(repo, g, &receipts);
518 let v = crate::verdict::verdict_for(g, &receipts, &ctx, ts);
519 let now = v.label();
520 let short = &verified_at_sha[..verified_at_sha.len().min(8)];
521 println!(
522 " [{}] {:?} — test {:?} frozen@{short} now: {now}",
523 g.supports, g.claim, reference
524 );
525 }
526 Some(Check::Person { reference }) => {
527 println!(" [{}] {:?} — person {:?}", g.supports, g.claim, reference);
528 }
529 None => {
530 println!(" [{}] {:?}", g.supports, g.claim);
531 }
532 }
533 }
534 ExitCode::SUCCESS
535}
536
537fn self_test_golden() -> ExitCode {
539 let genesis = Tick {
540 id: String::new(),
541 parent_id: "".into(),
542 observe: "evaluating retrieval backend".into(),
543 decision: "freeze the retrieval schema for v2".into(),
544 grounds: vec![
545 Ground {
546 claim: "team still wants a frozen schema".into(),
547 supports: "chosen".into(),
548 check: Some(Check::Person {
549 reference: "Q3 infra review".into(),
550 }),
551 },
552 Ground {
553 claim: "pgvector would lock our schema".into(),
554 supports: "rejected:pgvector".into(),
555 check: None,
556 },
557 ],
558 status: "live".into(),
559 held_since: "".into(),
560 blame: "Wang Yu".into(),
561 authority: None,
562 };
563 let case1 = Tick {
564 id: String::new(),
565 parent_id: "7b21f0a4c8de".into(),
566 observe: "multi-pod restore-safety counter — chat-room R2289→R2290".into(),
567 decision: "restore-safety counter DB-backed; reject Redis".into(),
568 grounds: vec![
569 Ground {
570 claim: "Argus introduces no Redis; multi-pod coord via existing DB".into(),
571 supports: "chosen".into(),
572 check: Some(Check::Test {
573 reference: "pytest tests/test_redis_absent.py".into(),
574 verified_at_sha: "d308afac1b2c3d4e5f60718293a4b5c6d7e8f901".into(),
575 counter_test:
576 "pytest tests/test_redis_absent.py::test_redis_injection_flips_red".into(),
577 liveness: Liveness {
578 platforms: vec!["linux-ci".into()],
579 triggered_by: vec!["pyproject.toml".into()],
580 surfaces: vec!["pyproject-deps".into()],
581 },
582 }),
583 },
584 Ground {
585 claim: "team still wants 0-Redis posture".into(),
586 supports: "chosen".into(),
587 check: Some(Check::Person {
588 reference: "Q3 infra review".into(),
589 }),
590 },
591 Ground {
592 claim: "Redis would add a new infra dependency".into(),
593 supports: "rejected:Redis".into(),
594 check: None,
595 },
596 ],
597 status: "live".into(),
598 held_since: "".into(),
599 blame: "Wang Yu".into(),
600 authority: None,
601 };
602 let mut ok = true;
603 for (name, t, want) in [
604 ("genesis", &genesis, "e2b337f53a1f"),
605 ("case1", &case1, "638c47b0c9dd"),
606 ] {
607 let got = compute_id(t);
608 let pass = got == want;
609 ok &= pass;
610 println!(
611 "{} {name}: {got} (want {want})",
612 if pass { "✓" } else { "✗" }
613 );
614 }
615 if ok {
616 ExitCode::SUCCESS
617 } else {
618 ExitCode::FAILURE
619 }
620}