1#![allow(clippy::too_many_lines)]
20
21use std::collections::{BTreeMap, HashMap};
22use std::net::SocketAddr;
23use std::path::{Path, PathBuf};
24use std::sync::{Arc, Mutex};
25use std::time::Instant;
26
27use axum::{
28 Json, Router,
29 extract::{Form, Path as AxumPath, Query, State},
30 http::StatusCode,
31 response::{Html, IntoResponse, Redirect, Response},
32 routing::{get, post},
33};
34use rand::RngCore;
35use serde::Deserialize;
36use tower_http::cors::CorsLayer;
37
38use crate::bridge::{Bridge, BridgeStatus};
39use crate::bundle::{FindingBundle, Replication};
40use crate::causal_reasoning::{Identifiability, audit_frontier, summarize_audit};
41use crate::project::Project;
42use crate::proposals::{self, StateProposal};
43use crate::repo;
44use crate::state::{self, ReviseOptions};
45
46const TOKENS_CSS: &str = include_str!("../embedded/web/styles/tokens.css");
47const WORKBENCH_CSS: &str = include_str!("../embedded/web/styles/workbench.css");
48
49const FAVICON_SVG: &str = include_str!("../embedded/assets/brand/favicon.svg");
50
51const WB_VERSION: &str = "0.55.0"; #[derive(Clone)]
63struct AppState {
64 repo_path: Arc<PathBuf>,
65 form_cache: Arc<Mutex<HashMap<String, FormCacheEntry>>>,
66}
67
68const FORM_CACHE_TTL: std::time::Duration = std::time::Duration::from_secs(300);
73
74#[derive(Clone)]
75struct FormCacheEntry {
76 inserted_at: Instant,
77 state: FormState,
78}
79
80#[allow(dead_code)]
87#[derive(Clone)]
88enum FormState {
89 LocatorRepair {
90 atom_id: String,
91 locator: String,
92 reviewer: String,
93 reason: String,
94 error: String,
95 },
96 SpanRepair {
97 finding_id: String,
98 section: String,
99 text: String,
100 reviewer: String,
101 reason: String,
102 error: String,
103 },
104 EntityResolve {
105 finding_id: String,
106 entity_name: String,
107 source: String,
108 id: String,
109 confidence: f64,
110 matched_name: Option<String>,
111 resolution_method: String,
112 reviewer: String,
113 reason: String,
114 error: String,
115 },
116 Promote {
117 finding_id: String,
118 status: String,
119 reviewer: String,
120 reason: String,
121 error: String,
122 },
123 ConflictResolve {
124 conflict_event_id: String,
125 resolution_note: String,
126 winning_proposal_id: Option<String>,
127 reviewer: String,
128 error: String,
129 },
130 ReplicationAdd {
131 finding_id: String,
132 outcome: String,
133 attempted_by: String,
134 conditions_text: String,
135 source_title: String,
136 doi: String,
137 pmid: String,
138 note: String,
139 error: String,
140 },
141 PredictionAdd {
142 finding_id: String,
143 claim_text: String,
144 resolves_by: String,
145 resolution_criterion: String,
146 expected_outcome: String,
147 made_by: String,
148 confidence: f64,
149 conditions_text: String,
150 error: String,
151 },
152}
153
154fn new_form_token() -> String {
158 let mut buf = [0u8; 16];
159 rand::thread_rng().fill_bytes(&mut buf);
160 buf.iter().map(|b| format!("{b:02x}")).collect()
161}
162
163fn store_form_state(state: &AppState, fs: FormState) -> String {
164 let token = new_form_token();
165 let mut cache = match state.form_cache.lock() {
166 Ok(c) => c,
167 Err(e) => e.into_inner(),
168 };
169 let now = Instant::now();
171 cache.retain(|_, entry| now.duration_since(entry.inserted_at) < FORM_CACHE_TTL);
172 cache.insert(
173 token.clone(),
174 FormCacheEntry {
175 inserted_at: now,
176 state: fs,
177 },
178 );
179 token
180}
181
182fn take_form_state(state: &AppState, token: &str) -> Option<FormState> {
183 let mut cache = match state.form_cache.lock() {
184 Ok(c) => c,
185 Err(e) => e.into_inner(),
186 };
187 let now = Instant::now();
188 cache.retain(|_, entry| now.duration_since(entry.inserted_at) < FORM_CACHE_TTL);
189 cache.remove(token).map(|e| e.state)
190}
191
192#[derive(Debug, Deserialize, Default)]
193struct ErrorTokenQuery {
194 #[serde(default)]
195 error: Option<String>,
196}
197
198fn render_error_banner(message: &str) -> String {
203 format!(
204 r#"<div class="wb-card" style="border-left:3px solid var(--gold,#b71c1c);background:#fbeaea;"><p style="margin:0;color:#b71c1c;font-weight:500;">Submission rejected by validator</p><p style="margin:0.3rem 0 0 0;color:var(--ink-1);font-size:0.92rem;">{msg}</p></div>"#,
205 msg = escape_html(message)
206 )
207}
208
209pub async fn run(repo_path: PathBuf, port: u16, open_browser: bool) -> Result<(), String> {
212 if !repo_path.join(".vela").is_dir() {
213 return Err(format!(
214 "no .vela/ found at {} — run `vela init` first",
215 repo_path.display()
216 ));
217 }
218 let _ =
220 repo::load_from_path(&repo_path).map_err(|e| format!("failed to load .vela/ repo: {e}"))?;
221
222 let state = AppState {
223 repo_path: Arc::new(repo_path),
224 form_cache: Arc::new(Mutex::new(HashMap::new())),
225 };
226
227 let app = Router::new()
228 .route("/", get(page_dashboard))
229 .route("/findings", get(page_findings))
230 .route("/findings/{vf_id}", get(page_finding_detail))
231 .route("/proposals", get(page_proposals))
232 .route("/proposals/{vpr_id}/preview", get(page_proposal_preview))
233 .route("/proposals/{vpr_id}/accept", post(post_proposal_accept))
234 .route("/proposals/{vpr_id}/reject", post(post_proposal_reject))
235 .route("/proposals/{vpr_id}/revision", post(post_proposal_revision))
236 .route("/artifact-packets", get(page_artifact_packets))
237 .route("/audit", get(page_audit))
238 .route("/bridges", get(page_bridges))
239 .route("/bridges/{vbr_id}/confirm", post(post_bridge_confirm))
240 .route("/bridges/{vbr_id}/refute", post(post_bridge_refute))
241 .route("/negative-results", get(page_negative_results))
244 .route("/trajectories", get(page_trajectories))
245 .route("/tiers", get(page_tiers))
246 .route("/constellation", get(page_constellation))
253 .route(
254 "/api/propagate/{vf_id}",
255 post(post_api_propagate_confidence),
256 )
257 .route("/replay/{vf_id}", get(page_replay))
261 .route("/review/inbox", get(page_review_inbox))
265 .route(
266 "/review/locator-repair/{atom_id}",
267 get(page_review_locator_repair),
268 )
269 .route("/review/locator-repair", post(post_review_locator_repair))
270 .route(
271 "/review/span-repair/{finding_id}",
272 get(page_review_span_repair),
273 )
274 .route("/review/span-repair", post(post_review_span_repair))
275 .route(
276 "/review/entity-resolve/{finding_id}",
277 get(page_review_entity_resolve),
278 )
279 .route("/review/entity-resolve", post(post_review_entity_resolve))
280 .route("/review/promote/{finding_id}", get(page_review_promote))
285 .route("/review/promote", post(post_review_promote))
286 .route(
290 "/review/conflict-resolve/{conflict_event_id}",
291 get(page_review_conflict_resolve),
292 )
293 .route(
294 "/review/conflict-resolve",
295 post(post_review_conflict_resolve),
296 )
297 .route(
302 "/review/replication-add/{finding_id}",
303 get(page_review_replication_add),
304 )
305 .route("/review/replication-add", post(post_review_replication_add))
306 .route(
307 "/review/prediction-add/{finding_id}",
308 get(page_review_prediction_add),
309 )
310 .route("/review/prediction-add", post(post_review_prediction_add))
311 .route("/static/tokens.css", get(static_tokens_css))
312 .route("/static/workbench.css", get(static_workbench_css))
313 .route("/static/favicon.svg", get(static_favicon_svg))
314 .route("/healthz", get(healthz))
315 .layer(CorsLayer::permissive())
316 .with_state(state);
317
318 let addr: SocketAddr = ([127, 0, 0, 1], port).into();
319 let listener = tokio::net::TcpListener::bind(&addr)
320 .await
321 .map_err(|e| format!("failed to bind {addr}: {e}"))?;
322 let actual_addr = listener.local_addr().unwrap_or(addr);
323 let url = format!("http://{actual_addr}/");
324
325 println!("vela workbench listening on {url}");
326 if open_browser && let Err(e) = open_browser_at(&url) {
327 eprintln!("(could not auto-open browser: {e})");
328 }
329 println!("Ctrl-C to stop.");
330
331 axum::serve(listener, app)
332 .await
333 .map_err(|e| format!("axum serve: {e}"))
334}
335
336fn open_browser_at(url: &str) -> Result<(), String> {
337 #[cfg(target_os = "macos")]
338 let cmd = "open";
339 #[cfg(target_os = "linux")]
340 let cmd = "xdg-open";
341 #[cfg(target_os = "windows")]
342 let cmd = "explorer";
343
344 std::process::Command::new(cmd)
345 .arg(url)
346 .stdout(std::process::Stdio::null())
347 .stderr(std::process::Stdio::null())
348 .spawn()
349 .map(|_| ())
350 .map_err(|e| format!("{cmd}: {e}"))
351}
352
353fn escape_html(s: &str) -> String {
356 s.replace('&', "&")
357 .replace('<', "<")
358 .replace('>', ">")
359 .replace('"', """)
360}
361
362fn urlencode_path(s: &str) -> String {
367 let mut out = String::with_capacity(s.len());
368 for b in s.bytes() {
369 match b {
370 b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' | b':' => {
371 out.push(b as char)
372 }
373 _ => out.push_str(&format!("%{b:02X}")),
374 }
375 }
376 out
377}
378
379fn shell(active: &str, title: &str, eyebrow: &str, page_title: &str, body: &str) -> String {
380 let nav = |id: &str, href: &str, label: &str| -> String {
381 let on = if id == active {
382 " wb-rim__link--on"
383 } else {
384 ""
385 };
386 format!(r#"<a class="wb-rim__link{on}" href="{href}">{label}</a>"#)
387 };
388 let constellation_nav = [
389 nav("tiers", "/tiers", "08 · Tiers"),
390 nav("bridges", "/bridges", "09 · Bridges"),
391 nav("constellation", "/constellation", "10 · Constellation"),
392 ]
393 .join("");
394 let rim = format!(
395 r#"<aside class="wb-rim">
396 <div class="wb-rim__mark">
397 <a href="/" aria-label="Vela">
398 <span style="display:inline-block;width:26px;height:26px;background:#1a1a1a;border-radius:3px;color:#fff;font-family:ui-monospace,Menlo,monospace;font-size:11px;line-height:26px;text-align:center;font-weight:700;">v</span>
399 </a>
400 </div>
401 <nav class="wb-rim__nav" aria-label="Workbench">
402 {l1}
403 {l2}
404 {l3}
405 {l4}
406 {l5}
407 {l6}
408 {l7}
409 {l8}
410 </nav>
411 <div class="wb-rim__index">v{ver}</div>
412</aside>"#,
413 l1 = nav("dashboard", "/", "01 · Dashboard"),
414 l2 = nav("findings", "/findings", "02 · Findings"),
415 l3 = nav("proposals", "/proposals", "03 · Proposals"),
416 l4 = nav("packets", "/artifact-packets", "04 · Packets"),
417 l5 = nav("nulls", "/negative-results", "05 · Nulls"),
418 l6 = nav("trajectories", "/trajectories", "06 · Trajectories"),
419 l7 = nav("audit", "/audit", "07 · Audit"),
420 l8 = constellation_nav,
421 ver = WB_VERSION,
422 );
423 format!(
424 r#"<!doctype html>
425<html lang="en">
426<head>
427<meta charset="utf-8">
428<meta name="viewport" content="width=device-width,initial-scale=1">
429<title>{title_safe}</title>
430<link rel="icon" type="image/svg+xml" href="/static/favicon.svg">
431<link rel="stylesheet" href="/static/tokens.css">
432<link rel="stylesheet" href="/static/workbench.css">
433<style>
434 body {{ margin: 0; font-family: var(--font-text, system-ui, sans-serif); color: var(--ink-1, #1a1a1a); background: var(--bg-1, #fafaf6); }}
435 .wb {{ display: grid; grid-template-columns: 200px 1fr; min-height: 100vh; }}
436 .wb-rim {{ background: var(--bg-2, #f5f2ec); padding: 1rem 0.75rem; border-right: 1px solid var(--rule-2, #d8d4cc); }}
437 .wb-rim__mark {{ margin-bottom: 1.5rem; }}
438 .wb-rim__nav {{ display: flex; flex-direction: column; gap: 0.4rem; }}
439 .wb-rim__link {{ font-size: 0.86rem; color: var(--ink-2, #6b665d); text-decoration: none; padding: 0.3rem 0.5rem; border-radius: 2px; }}
440 .wb-rim__link--on {{ color: var(--ink-1, #1a1a1a); background: var(--bg-3, #ebe6dd); font-weight: 600; }}
441 .wb-rim__index {{ margin-top: 2rem; color: var(--ink-3, #a09a8d); font-size: 0.74rem; font-family: ui-monospace, Menlo, monospace; }}
442 .wb-content {{ padding: 1.5rem 2rem; max-width: 920px; }}
443 .wb-eyebrow {{ font-size: 0.78rem; text-transform: uppercase; letter-spacing: 0.08em; color: var(--ink-2, #6b665d); margin-bottom: 0.4rem; }}
444 .wb-title {{ font-size: 1.6rem; margin: 0 0 1rem 0; line-height: 1.2; }}
445 .wb-stats {{ display: grid; grid-template-columns: repeat(4, 1fr); gap: 1rem; margin: 1rem 0 1.5rem 0; padding: 0.85rem 1rem; border: 1px solid var(--rule-2, #d8d4cc); background: var(--bg-2, #f5f2ec); }}
446 .wb-stat__num {{ font-family: ui-monospace, Menlo, monospace; font-size: 1.3rem; font-weight: 600; }}
447 .wb-stat__label {{ font-size: 0.74rem; text-transform: uppercase; letter-spacing: 0.06em; color: var(--ink-2, #6b665d); }}
448 .wb-card {{ border: 1px solid var(--rule-2, #d8d4cc); padding: 0.85rem 1rem; margin: 0 0 0.85rem 0; }}
449 .wb-card h3 {{ margin: 0 0 0.4rem 0; font-size: 1rem; }}
450 .wb-card p {{ margin: 0.2rem 0; font-size: 0.92rem; line-height: 1.55; }}
451 .wb-chip {{ display: inline-block; padding: 0.05em 0.5em; border-radius: 2px; font-size: 0.72rem; text-transform: uppercase; letter-spacing: 0.06em; margin-right: 0.4em; }}
452 .wb-chip--ok {{ background: #d6e4d3; color: #2f5d3a; }}
453 .wb-chip--warn {{ background: #efe2c0; color: #8a6d1f; }}
454 .wb-chip--lost {{ background: #efd1cf; color: #872c2c; }}
455 .wb-table {{ width: 100%; border-collapse: collapse; font-size: 0.92rem; }}
456 .wb-table th, .wb-table td {{ text-align: left; padding: 0.4rem 0.6rem; border-bottom: 1px solid var(--rule-2, #d8d4cc); }}
457 .wb-table th {{ font-size: 0.74rem; text-transform: uppercase; letter-spacing: 0.06em; color: var(--ink-2, #6b665d); }}
458 .wb-actions form {{ display: inline-block; margin-right: 0.4em; }}
459 .wb-actions button {{ font-family: inherit; font-size: 0.78rem; padding: 0.25em 0.6em; border: 1px solid var(--rule-2, #d8d4cc); background: var(--bg-1, #fafaf6); cursor: pointer; border-radius: 2px; }}
460 .wb-actions button:hover {{ background: var(--bg-3, #ebe6dd); }}
461 code {{ background: var(--bg-3, #ebe6dd); padding: 0.05em 0.3em; border-radius: 2px; font-size: 0.88em; }}
462 a {{ color: var(--ink-1, #1a1a1a); }}
463</style>
464</head>
465<body>
466<div class="wb">
467{rim}
468<main class="wb-content">
469 <div class="wb-eyebrow">{eyebrow}</div>
470 <h1 class="wb-title">{page_title}</h1>
471 {body}
472</main>
473</div>
474</body>
475</html>
476"#,
477 title_safe = escape_html(title),
478 )
479}
480
481fn frontier_label(p: &Project) -> String {
482 p.project.name.clone()
483}
484
485async fn page_dashboard(State(state): State<AppState>) -> Response {
488 let repo_path = state.repo_path.clone();
489 let project = match repo::load_from_path(&repo_path) {
490 Ok(p) => p,
491 Err(e) => return error_page("dashboard", "Could not load frontier", &e),
492 };
493 let label = frontier_label(&project);
494
495 let mut pending = 0usize;
496 let mut by_kind: BTreeMap<String, usize> = BTreeMap::new();
497 for p in &project.proposals {
498 if p.status == "pending_review" {
499 pending += 1;
500 *by_kind.entry(p.kind.clone()).or_insert(0) += 1;
501 }
502 }
503
504 let audit = audit_frontier(&project);
505 let audit_summary = summarize_audit(&audit);
506
507 let bridges = list_bridges(&repo_path);
508 let bridge_total = bridges.len();
509 let bridge_confirmed = bridges
510 .iter()
511 .filter(|b| b.status == BridgeStatus::Confirmed)
512 .count();
513 let bridge_derived = bridges
514 .iter()
515 .filter(|b| b.status == BridgeStatus::Derived)
516 .count();
517
518 let mut targets_with_success = std::collections::HashSet::new();
519 let mut failed_replications = 0usize;
520 for r in &project.replications {
521 if r.outcome == "replicated" {
522 targets_with_success.insert(r.target_finding.clone());
523 } else if r.outcome == "failed" {
524 failed_replications += 1;
525 }
526 }
527
528 let stats_html = format!(
532 r#"<div class="wb-stats">
533 <div><div class="wb-stat__num">{}</div><div class="wb-stat__label">findings</div></div>
534 <div><div class="wb-stat__num">{}</div><div class="wb-stat__label">nulls</div></div>
535 <div><div class="wb-stat__num">{}</div><div class="wb-stat__label">trajectories</div></div>
536 <div><div class="wb-stat__num">{}</div><div class="wb-stat__label">events</div></div>
537</div>
538<div class="wb-stats" style="margin-top:0.6rem;">
539 <div><div class="wb-stat__num">{}</div><div class="wb-stat__label">pending</div></div>
540 <div><div class="wb-stat__num">{}</div><div class="wb-stat__label">bridges</div></div>
541 <div><div class="wb-stat__num">{}</div><div class="wb-stat__label">restricted</div></div>
542 <div><div class="wb-stat__num">{}</div><div class="wb-stat__label">classified</div></div>
543</div>"#,
544 project.findings.len(),
545 project.negative_results.len(),
546 project.trajectories.len(),
547 project.events.len(),
548 pending,
549 bridge_total,
550 project
551 .findings
552 .iter()
553 .filter(|f| matches!(f.access_tier, crate::access_tier::AccessTier::Restricted))
554 .count()
555 + project
556 .negative_results
557 .iter()
558 .filter(|n| matches!(n.access_tier, crate::access_tier::AccessTier::Restricted))
559 .count()
560 + project
561 .trajectories
562 .iter()
563 .filter(|t| matches!(t.access_tier, crate::access_tier::AccessTier::Restricted))
564 .count(),
565 project
566 .findings
567 .iter()
568 .filter(|f| matches!(f.access_tier, crate::access_tier::AccessTier::Classified))
569 .count()
570 + project
571 .negative_results
572 .iter()
573 .filter(|n| matches!(n.access_tier, crate::access_tier::AccessTier::Classified))
574 .count()
575 + project
576 .trajectories
577 .iter()
578 .filter(|t| matches!(t.access_tier, crate::access_tier::AccessTier::Classified))
579 .count(),
580 );
581
582 let mut cards = String::new();
583
584 if pending > 0 {
585 let parts: Vec<String> = by_kind
586 .iter()
587 .map(|(k, n)| format!("<code>{n}</code> {}", escape_html(k)))
588 .collect();
589 cards.push_str(&format!(
590 r#"<div class="wb-card">
591 <h3><span class="wb-chip wb-chip--warn">inbox</span>{} pending proposals</h3>
592 <p>{}</p>
593 <p><a href="/audit">Open audit →</a></p>
594</div>"#,
595 pending,
596 parts.join(" · ")
597 ));
598 }
599
600 if audit_summary.underidentified > 0 || audit_summary.conditional > 0 {
601 let chip_kind = if audit_summary.underidentified > 0 {
602 "lost"
603 } else {
604 "warn"
605 };
606 cards.push_str(&format!(
607 r#"<div class="wb-card">
608 <h3><span class="wb-chip wb-chip--{chip}">audit</span>identifiability</h3>
609 <p><strong>{}</strong> underidentified · <strong>{}</strong> conditional · <strong>{}</strong> identified</p>
610 <p><a href="/audit">Open audit →</a></p>
611</div>"#,
612 audit_summary.underidentified,
613 audit_summary.conditional,
614 audit_summary.identified,
615 chip = chip_kind,
616 ));
617 }
618
619 if bridge_total > 0 {
620 cards.push_str(&format!(
621 r#"<div class="wb-card">
622 <h3><span class="wb-chip wb-chip--ok">bridges</span>cross-frontier composition</h3>
623 <p><strong>{bridge_total}</strong> total · <strong>{bridge_confirmed}</strong> confirmed · <strong>{bridge_derived}</strong> awaiting review</p>
624 <p><a href="/bridges">Open bridges →</a></p>
625</div>"#
626 ));
627 }
628
629 if !project.replications.is_empty() {
630 cards.push_str(&format!(
631 r#"<div class="wb-card">
632 <h3><span class="wb-chip wb-chip--ok">replications</span>empirical bedrock</h3>
633 <p><strong>{}</strong> records · <strong>{}</strong> findings replicated · <strong>{}</strong> failed</p>
634</div>"#,
635 project.replications.len(),
636 targets_with_success.len(),
637 failed_replications
638 ));
639 }
640
641 let body = format!("{stats_html}{cards}");
642
643 Html(shell(
644 "dashboard",
645 &format!("Vela workbench · {label}"),
646 "Workbench",
647 &escape_html(&label),
648 &body,
649 ))
650 .into_response()
651}
652
653async fn page_findings(State(state): State<AppState>) -> Response {
654 let project = match repo::load_from_path(&state.repo_path) {
655 Ok(p) => p,
656 Err(e) => return error_page("findings", "Could not load frontier", &e),
657 };
658
659 let mut rows = String::new();
660 for f in project.findings.iter().take(500) {
661 let conf_pct = (f.confidence.score * 100.0).round() as i64;
662 let claim = f.assertion.causal_claim.map_or("—", |c| match c {
663 crate::bundle::CausalClaim::Correlation => "correlation",
664 crate::bundle::CausalClaim::Mediation => "mediation",
665 crate::bundle::CausalClaim::Intervention => "intervention",
666 });
667 let assertion_short: String = f.assertion.text.chars().take(110).collect();
668 rows.push_str(&format!(
669 r#"<tr>
670 <td><a href="/findings/{vf}"><code>{vf_short}</code></a></td>
671 <td>{conf}%</td>
672 <td>{claim}</td>
673 <td>{text}</td>
674</tr>"#,
675 vf = escape_html(&f.id),
676 vf_short = escape_html(&f.id),
677 conf = conf_pct,
678 claim = claim,
679 text = escape_html(&assertion_short),
680 ));
681 }
682
683 let body = format!(
684 r#"<table class="wb-table">
685 <thead>
686 <tr><th>vf_id</th><th>conf</th><th>claim</th><th>assertion</th></tr>
687 </thead>
688 <tbody>
689{rows}
690 </tbody>
691</table>"#
692 );
693
694 Html(shell(
695 "findings",
696 "Findings",
697 "Workbench",
698 &format!("{} findings", project.findings.len()),
699 &body,
700 ))
701 .into_response()
702}
703
704async fn page_finding_detail(
705 AxumPath(vf_id): AxumPath<String>,
706 State(state): State<AppState>,
707) -> Response {
708 let project = match repo::load_from_path(&state.repo_path) {
709 Ok(p) => p,
710 Err(e) => return error_page("findings", "Could not load frontier", &e),
711 };
712 let Some(f) = project.findings.iter().find(|f| f.id == vf_id) else {
713 return error_page(
714 "findings",
715 "Finding not found",
716 &format!("no finding with id {vf_id}"),
717 );
718 };
719
720 let conf_pct = (f.confidence.score * 100.0).round() as i64;
721
722 let mut links_html = String::new();
723 if !f.links.is_empty() {
724 links_html.push_str(r#"<table class="wb-table"><thead><tr><th>type</th><th>target</th><th>mechanism</th></tr></thead><tbody>"#);
725 for l in &f.links {
726 let mech = l.mechanism.map_or("—".to_string(), |m| {
727 use crate::bundle::Mechanism;
728 match m {
729 Mechanism::Linear { sign, slope } => {
730 format!("linear {sign:?} slope {slope:.2}")
731 }
732 Mechanism::Monotonic { sign } => format!("monotonic {sign:?}"),
733 Mechanism::Threshold { sign, threshold } => {
734 format!("threshold {sign:?} {threshold:.2}")
735 }
736 Mechanism::Saturating { sign, half_max } => {
737 format!("saturating {sign:?} half_max {half_max:.2}")
738 }
739 Mechanism::Unknown => "unknown".into(),
740 }
741 });
742 links_html.push_str(&format!(
743 "<tr><td><code>{}</code></td><td><code>{}</code></td><td>{}</td></tr>",
744 escape_html(&l.link_type),
745 escape_html(&l.target),
746 escape_html(&mech)
747 ));
748 }
749 links_html.push_str("</tbody></table>");
750 }
751
752 let assertion = escape_html(&f.assertion.text);
753
754 let source_block = {
756 let mut parts: Vec<String> = Vec::new();
757 if let Some(doi) = f.provenance.doi.as_deref().filter(|s| !s.is_empty()) {
758 parts.push(format!(
759 "<a href=\"https://doi.org/{doi}\" target=\"_blank\" rel=\"noopener\"><code>doi:{doi}</code></a>",
760 doi = escape_html(doi)
761 ));
762 }
763 if let Some(pmid) = f.provenance.pmid.as_deref().filter(|s| !s.is_empty()) {
764 parts.push(format!(
765 "<a href=\"https://pubmed.ncbi.nlm.nih.gov/{pmid}/\" target=\"_blank\" rel=\"noopener\"><code>pmid:{pmid}</code></a>",
766 pmid = escape_html(pmid)
767 ));
768 }
769 if let Some(y) = f.provenance.year {
770 parts.push(format!("{y}"));
771 }
772 if let Some(j) = f.provenance.journal.as_deref().filter(|s| !s.is_empty()) {
773 parts.push(escape_html(j));
774 }
775 if parts.is_empty() {
776 "<span style=\"color:var(--ink-3);\">no source metadata</span>".to_string()
777 } else {
778 parts.join(" · ")
779 }
780 };
781
782 let mut spans_block = String::new();
784 if f.evidence.evidence_spans.is_empty() {
785 spans_block.push_str(
786 r#"<p style="color:var(--ink-3);font-size:0.86rem;">No evidence_spans attached. Repair via /review/span-repair if the source has retrievable text.</p>"#,
787 );
788 } else {
789 for s in &f.evidence.evidence_spans {
790 let section = s
791 .get("section")
792 .and_then(|v| v.as_str())
793 .unwrap_or("(unsectioned)");
794 let text = s.get("text").and_then(|v| v.as_str()).unwrap_or("");
795 if text.is_empty() {
796 continue;
797 }
798 spans_block.push_str(&format!(
799 r#"<blockquote style="color:var(--ink-2);font-size:0.92rem;margin:0.4rem 0 0.6rem 0;border-left:2px solid var(--ink-4);padding-left:0.8rem;"><strong>[{section}]</strong> {text}</blockquote>"#,
800 section = escape_html(section),
801 text = escape_html(text),
802 ));
803 }
804 }
805
806 let review_state_label = match &f.flags.review_state {
811 Some(crate::bundle::ReviewState::Accepted) => "<code>accepted</code>",
812 Some(crate::bundle::ReviewState::Contested) => "<code>contested</code>",
813 Some(crate::bundle::ReviewState::NeedsRevision) => "<code>needs_revision</code>",
814 Some(crate::bundle::ReviewState::Rejected) => "<code>rejected</code>",
815 None => "<span style=\"color:var(--ink-3);\">(unset)</span>",
816 };
817 let mut events_for_finding: Vec<&crate::events::StateEvent> = project
818 .events
819 .iter()
820 .filter(|e| e.target.id == f.id)
821 .collect();
822 events_for_finding.sort_by(|a, b| a.timestamp.cmp(&b.timestamp));
823 let mut history_block = String::new();
824 if events_for_finding.is_empty() {
825 history_block.push_str(
826 r#"<p style="color:var(--ink-3);font-size:0.86rem;">No canonical events recorded against this finding yet.</p>"#,
827 );
828 } else {
829 history_block.push_str(r#"<table class="wb-table"><thead><tr><th>when</th><th>kind</th><th>actor</th><th>reason</th></tr></thead><tbody>"#);
830 for ev in &events_for_finding {
831 let when = if ev.timestamp.len() >= 10 {
832 &ev.timestamp[..10]
833 } else {
834 ev.timestamp.as_str()
835 };
836 history_block.push_str(&format!(
837 r#"<tr><td><code>{when}</code></td><td><code>{kind}</code></td><td><code>{actor}</code></td><td>{reason}</td></tr>"#,
838 when = escape_html(when),
839 kind = escape_html(&ev.kind),
840 actor = escape_html(&ev.actor.id),
841 reason = escape_html(&ev.reason),
842 ));
843 }
844 history_block.push_str("</tbody></table>");
845 }
846
847 let caveats_block = if f.flags.review_state.is_none() && f.evidence.evidence_spans.is_empty() {
849 r#"<p style="color:var(--ink-3);font-size:0.86rem;">Draft finding with no evidence_spans yet.</p>"#.to_string()
850 } else {
851 String::new()
852 };
853
854 let body = format!(
855 r#"<div class="wb-stats">
856 <div><div class="wb-stat__num">{conf_pct}%</div><div class="wb-stat__label">confidence</div></div>
857 <div><div class="wb-stat__num">{n_links}</div><div class="wb-stat__label">links</div></div>
858 <div><div class="wb-stat__num">{n_events}</div><div class="wb-stat__label">events</div></div>
859 <div><div class="wb-stat__num">{atype}</div><div class="wb-stat__label">type</div></div>
860</div>
861<div class="wb-card">
862 <h3>Assertion</h3>
863 <p>{assertion}</p>
864 <p style="color:var(--ink-3);font-size:0.86rem;margin-top:0.4rem;">Source: {source_block} · Review state: {review_state_label} · Schema version: {ver}</p>
865 {caveats_block}
866</div>
867<div class="wb-card">
868 <h3>Evidence</h3>
869 {spans_block}
870</div>
871<div class="wb-card">
872 <h3>Links</h3>
873 {links_html}
874</div>
875<div class="wb-card">
876 <h3>History</h3>
877 {history_block}
878</div>"#,
879 n_links = f.links.len(),
880 n_events = events_for_finding.len(),
881 atype = escape_html(&f.assertion.assertion_type),
882 ver = f.version,
883 );
884
885 Html(shell(
886 "findings",
887 &format!("{} · {}", vf_id, project.project.name),
888 "Finding",
889 &vf_id,
890 &body,
891 ))
892 .into_response()
893}
894
895async fn page_proposals(State(state): State<AppState>) -> Response {
896 let project = match repo::load_from_path(&state.repo_path) {
897 Ok(p) => p,
898 Err(e) => return error_page("proposals", "Could not load frontier", &e),
899 };
900
901 let mut proposals = project.proposals.clone();
902 proposals.sort_by(|a, b| {
903 status_rank(&a.status)
904 .cmp(&status_rank(&b.status))
905 .then(a.created_at.cmp(&b.created_at))
906 .then(a.id.cmp(&b.id))
907 });
908
909 let pending = proposals
910 .iter()
911 .filter(|proposal| proposal.status == "pending_review")
912 .count();
913 let needs_revision = proposals
914 .iter()
915 .filter(|proposal| proposal.status == "needs_revision")
916 .count();
917 let applied = proposals
918 .iter()
919 .filter(|proposal| proposal.status == "applied")
920 .count();
921
922 let rows = proposals
923 .iter()
924 .take(300)
925 .map(render_proposal_row)
926 .collect::<String>();
927 let body = format!(
928 r#"<div class="wb-stats">
929 <div><div class="wb-stat__num">{pending}</div><div class="wb-stat__label">pending</div></div>
930 <div><div class="wb-stat__num">{needs_revision}</div><div class="wb-stat__label">revision</div></div>
931 <div><div class="wb-stat__num">{applied}</div><div class="wb-stat__label">applied</div></div>
932 <div><div class="wb-stat__num">{total}</div><div class="wb-stat__label">total</div></div>
933</div>
934<div class="wb-card">
935 <h3>Proposal inbox</h3>
936 <p>External runtime output is source material. Validate the packet, preview the state diff, then accept, reject, or request revision.</p>
937 <pre><code>vela bridge-kit validate packet.json --json
938vela proposals preview FRONTIER vpr_... --json</code></pre>
939</div>
940<table class="wb-table">
941 <thead>
942 <tr><th>status</th><th>proposal</th><th>target</th><th>packet/source</th><th>actions</th></tr>
943 </thead>
944 <tbody>
945 {rows}
946 </tbody>
947</table>"#,
948 total = proposals.len(),
949 );
950
951 Html(shell(
952 "proposals",
953 &format!("Proposal inbox · {}", project.project.name),
954 "Workbench",
955 "Proposal inbox",
956 &body,
957 ))
958 .into_response()
959}
960
961async fn page_proposal_preview(
962 AxumPath(vpr_id): AxumPath<String>,
963 State(state): State<AppState>,
964) -> Response {
965 let project = match repo::load_from_path(&state.repo_path) {
966 Ok(p) => p,
967 Err(e) => return error_page("proposals", "Could not load frontier", &e),
968 };
969 let Some(proposal) = project
970 .proposals
971 .iter()
972 .find(|proposal| proposal.id == vpr_id)
973 else {
974 return error_page("proposals", "Proposal not found", &vpr_id);
975 };
976 let preview = if matches!(
977 proposal.status.as_str(),
978 "pending_review" | "accepted" | "applied"
979 ) {
980 match proposals::preview_at_path(&state.repo_path, &vpr_id, "reviewer:workbench") {
981 Ok(preview) => Some(preview),
982 Err(e) => return error_page("proposals", "Could not preview proposal", &e),
983 }
984 } else {
985 None
986 };
987 let findings_delta = preview.as_ref().map_or(0, |preview| preview.findings_delta);
988 let artifacts_delta = preview
989 .as_ref()
990 .map_or(0, |preview| preview.artifacts_delta);
991 let events_delta = preview.as_ref().map_or(0, |preview| preview.events_delta);
992 let event_id = preview
993 .as_ref()
994 .map(|preview| preview.applied_event_id.clone())
995 .or_else(|| proposal.applied_event_id.clone())
996 .unwrap_or_else(|| "event not applied".to_string());
997 let actions = if proposal.status == "pending_review" || proposal.status == "needs_revision" {
998 format!(
999 r#"<div class="wb-actions">
1000 <form method="post" action="/proposals/{id}/accept">
1001 <input type="hidden" name="reviewer" value="reviewer:workbench">
1002 <input type="hidden" name="reason" value="Accepted from local workbench review.">
1003 <button type="submit">Accept</button>
1004 </form>
1005 <form method="post" action="/proposals/{id}/revision">
1006 <input type="hidden" name="reviewer" value="reviewer:workbench">
1007 <input type="hidden" name="reason" value="Needs clearer artifact or evidence scope.">
1008 <button type="submit">Request revision</button>
1009 </form>
1010 <form method="post" action="/proposals/{id}/reject">
1011 <input type="hidden" name="reviewer" value="reviewer:workbench">
1012 <input type="hidden" name="reason" value="Rejected from local workbench review.">
1013 <button type="submit">Reject</button>
1014 </form>
1015</div>"#,
1016 id = escape_html(&proposal.id)
1017 )
1018 } else {
1019 format!(
1020 r#"<div class="wb-card"><p>This proposal is <code>{}</code>. It is shown as review history.</p></div>"#,
1021 escape_html(&proposal.status)
1022 )
1023 };
1024 let diff_text = if proposal.status == "applied" {
1025 format!(
1026 "This proposal already emitted <code>{}</code>. The preview reports the recorded event and leaves the frontier unchanged.",
1027 escape_html(&event_id)
1028 )
1029 } else if proposal.status == "rejected" {
1030 "This proposal was rejected. It remains visible as review history and is not applied to the frontier.".to_string()
1031 } else {
1032 format!(
1033 "Accepting this proposal would emit <code>{}</code> and mutate the in-memory frontier by the deltas above. This preview has not written to disk.",
1034 escape_html(&event_id)
1035 )
1036 };
1037 let body = format!(
1038 r#"<div class="wb-card">
1039 <h3><span class="wb-chip wb-chip--warn">preview</span>{id}</h3>
1040 <p>{reason}</p>
1041 <p><code>{kind}</code> targets <code>{target_type}:{target_id}</code></p>
1042</div>
1043<div class="wb-stats">
1044 <div><div class="wb-stat__num">{findings_delta:+}</div><div class="wb-stat__label">findings</div></div>
1045 <div><div class="wb-stat__num">{artifacts_delta:+}</div><div class="wb-stat__label">artifacts</div></div>
1046 <div><div class="wb-stat__num">{events_delta:+}</div><div class="wb-stat__label">events</div></div>
1047 <div><div class="wb-stat__num">stale</div><div class="wb-stat__label">proof after accept</div></div>
1048</div>
1049<div class="wb-card">
1050 <h3>Reviewer diff</h3>
1051 <p>{diff_text}</p>
1052 <p>External confidence, comments, and votes remain provenance. Only this review action changes canonical frontier state.</p>
1053</div>
1054<div class="wb-card">
1055 <h3>Source packet</h3>
1056 {packet}
1057</div>
1058{actions}
1059<div class="wb-card">
1060 <h3>Proposal payload</h3>
1061 <pre><code>{payload}</code></pre>
1062</div>"#,
1063 id = escape_html(&proposal.id),
1064 reason = escape_html(&proposal.reason),
1065 kind = escape_html(&proposal.kind),
1066 target_type = escape_html(&proposal.target.r#type),
1067 target_id = escape_html(&proposal.target.id),
1068 findings_delta = findings_delta,
1069 artifacts_delta = artifacts_delta,
1070 events_delta = events_delta,
1071 diff_text = diff_text,
1072 packet = render_packet_reference(proposal),
1073 actions = actions,
1074 payload = escape_html(&pretty_json(&proposal.payload)),
1075 );
1076
1077 Html(shell(
1078 "proposals",
1079 &format!("Proposal preview · {}", project.project.name),
1080 "Proposal",
1081 &proposal.id,
1082 &body,
1083 ))
1084 .into_response()
1085}
1086
1087async fn page_artifact_packets(State(state): State<AppState>) -> Response {
1088 let project = match repo::load_from_path(&state.repo_path) {
1089 Ok(p) => p,
1090 Err(e) => return error_page("packets", "Could not load frontier", &e),
1091 };
1092 let mut packets: BTreeMap<String, Vec<StateProposal>> = BTreeMap::new();
1093 for proposal in &project.proposals {
1094 if let Some(packet_id) = proposal_packet_id(proposal) {
1095 packets
1096 .entry(packet_id.to_string())
1097 .or_default()
1098 .push(proposal.clone());
1099 }
1100 }
1101 let cards = if packets.is_empty() {
1102 r#"<div class="wb-card"><p>No artifact packet provenance is present in the proposal ledger.</p></div>"#.to_string()
1103 } else {
1104 packets
1105 .iter()
1106 .map(|(packet_id, proposals)| {
1107 let applied = proposals
1108 .iter()
1109 .filter(|proposal| proposal.status == "applied")
1110 .count();
1111 let pending = proposals
1112 .iter()
1113 .filter(|proposal| proposal.status == "pending_review")
1114 .count();
1115 let proposal_links = proposals
1116 .iter()
1117 .map(|proposal| {
1118 format!(
1119 r#"<p><a href="/proposals/{id}/preview"><code>{id}</code></a> · {kind} · {status}</p>"#,
1120 id = escape_html(&proposal.id),
1121 kind = escape_html(&proposal.kind),
1122 status = escape_html(&proposal.status),
1123 )
1124 })
1125 .collect::<String>();
1126 format!(
1127 r#"<div class="wb-card">
1128 <h3><span class="wb-chip wb-chip--ok">packet</span>{packet_id}</h3>
1129 <p><strong>{count}</strong> generated proposals · <strong>{applied}</strong> applied · <strong>{pending}</strong> pending review.</p>
1130 {proposal_links}
1131</div>"#,
1132 packet_id = escape_html(packet_id),
1133 count = proposals.len(),
1134 )
1135 })
1136 .collect::<String>()
1137 };
1138 let body = format!(
1139 r#"<div class="wb-card">
1140 <h3>Artifact packet ledger</h3>
1141 <p>ScienceClaw-shaped packets are source material. Vela records their artifacts, claims, and open needs as reviewable proposals before state changes.</p>
1142</div>
1143{cards}"#
1144 );
1145 Html(shell(
1146 "packets",
1147 &format!("Artifact packets · {}", project.project.name),
1148 "Workbench",
1149 "Artifact packets",
1150 &body,
1151 ))
1152 .into_response()
1153}
1154
1155fn status_rank(status: &str) -> u8 {
1156 match status {
1157 "pending_review" => 0,
1158 "needs_revision" => 1,
1159 "accepted" => 2,
1160 "applied" => 3,
1161 "rejected" => 4,
1162 _ => 5,
1163 }
1164}
1165
1166fn render_proposal_row(proposal: &StateProposal) -> String {
1167 let chip = match proposal.status.as_str() {
1168 "applied" | "accepted" => "ok",
1169 "pending_review" | "needs_revision" => "warn",
1170 "rejected" => "lost",
1171 _ => "warn",
1172 };
1173 let packet = proposal_packet_id(proposal).unwrap_or("");
1174 let source = if packet.is_empty() {
1175 proposal
1176 .source_refs
1177 .first()
1178 .map(|value| escape_html(value))
1179 .unwrap_or_else(|| "source not declared".to_string())
1180 } else {
1181 format!("<code>{}</code>", escape_html(packet))
1182 };
1183 let actions = if matches!(
1184 proposal.status.as_str(),
1185 "pending_review" | "needs_revision"
1186 ) {
1187 format!(
1188 r#"<div class="wb-actions">
1189 <a href="/proposals/{id}/preview">Preview</a>
1190 <form method="post" action="/proposals/{id}/accept">
1191 <input type="hidden" name="reviewer" value="reviewer:workbench">
1192 <input type="hidden" name="reason" value="Accepted from local workbench review.">
1193 <button type="submit">Accept</button>
1194 </form>
1195 <form method="post" action="/proposals/{id}/revision">
1196 <input type="hidden" name="reviewer" value="reviewer:workbench">
1197 <input type="hidden" name="reason" value="Needs clearer artifact or evidence scope.">
1198 <button type="submit">Request revision</button>
1199 </form>
1200 <form method="post" action="/proposals/{id}/reject">
1201 <input type="hidden" name="reviewer" value="reviewer:workbench">
1202 <input type="hidden" name="reason" value="Rejected from local workbench review.">
1203 <button type="submit">Reject</button>
1204 </form>
1205</div>"#,
1206 id = escape_html(&proposal.id),
1207 )
1208 } else {
1209 format!(
1210 r#"<a href="/proposals/{}/preview">Preview</a>"#,
1211 escape_html(&proposal.id)
1212 )
1213 };
1214 format!(
1215 r#"<tr>
1216 <td><span class="wb-chip wb-chip--{chip}">{status}</span></td>
1217 <td><a href="/proposals/{id}/preview"><code>{id}</code></a><br>{reason}</td>
1218 <td><code>{target_type}:{target_id}</code><br><code>{kind}</code></td>
1219 <td>{source}</td>
1220 <td>{actions}</td>
1221</tr>"#,
1222 status = escape_html(&proposal.status),
1223 id = escape_html(&proposal.id),
1224 reason = escape_html(&proposal.reason),
1225 target_type = escape_html(&proposal.target.r#type),
1226 target_id = escape_html(&proposal.target.id),
1227 kind = escape_html(&proposal.kind),
1228 )
1229}
1230
1231fn proposal_packet_id(proposal: &StateProposal) -> Option<&str> {
1232 proposal
1233 .payload
1234 .get("artifact_packet")
1235 .and_then(|packet| packet.get("packet_id"))
1236 .and_then(|value| value.as_str())
1237 .or_else(|| {
1238 proposal
1239 .payload
1240 .get("artifact_packet_id")
1241 .and_then(|value| value.as_str())
1242 })
1243}
1244
1245fn render_packet_reference(proposal: &StateProposal) -> String {
1246 let Some(packet) = proposal.payload.get("artifact_packet") else {
1247 return "<p>No artifact packet metadata is attached.</p>".to_string();
1248 };
1249 let packet_id = packet
1250 .get("packet_id")
1251 .and_then(|value| value.as_str())
1252 .unwrap_or("packet id unavailable");
1253 let producer = packet
1254 .get("producer")
1255 .and_then(|producer| producer.get("id"))
1256 .and_then(|value| value.as_str())
1257 .unwrap_or("producer unavailable");
1258 let artifact_ids = packet
1259 .get("external_artifact_ids")
1260 .and_then(|value| value.as_array())
1261 .map(|ids| {
1262 ids.iter()
1263 .filter_map(|value| value.as_str())
1264 .map(|id| format!("<code>{}</code>", escape_html(id)))
1265 .collect::<Vec<_>>()
1266 .join(" ")
1267 })
1268 .unwrap_or_default();
1269 format!(
1270 r#"<p><code>{}</code> from <code>{}</code></p><p>{}</p>"#,
1271 escape_html(packet_id),
1272 escape_html(producer),
1273 artifact_ids
1274 )
1275}
1276
1277fn pretty_json(value: &serde_json::Value) -> String {
1278 serde_json::to_string_pretty(value).unwrap_or_else(|_| "{}".to_string())
1279}
1280
1281async fn page_audit(State(state): State<AppState>) -> Response {
1282 let project = match repo::load_from_path(&state.repo_path) {
1283 Ok(p) => p,
1284 Err(e) => return error_page("audit", "Could not load frontier", &e),
1285 };
1286
1287 let mut entries = audit_frontier(&project);
1288 let summary = summarize_audit(&entries);
1289 entries.retain(|e| {
1290 matches!(
1291 e.verdict,
1292 Identifiability::Underidentified | Identifiability::Conditional
1293 )
1294 });
1295
1296 let mut rows = String::new();
1297 for e in &entries {
1298 let chip = match e.verdict {
1299 Identifiability::Underidentified => "lost",
1300 Identifiability::Conditional => "warn",
1301 _ => continue,
1302 };
1303 let claim = e
1304 .causal_claim
1305 .map_or("—".to_string(), |c| format!("{c:?}").to_lowercase());
1306 let grade = e
1307 .causal_evidence_grade
1308 .map_or("—".to_string(), |g| format!("{g:?}").to_lowercase());
1309 let text: String = e.assertion_text.chars().take(120).collect();
1310 rows.push_str(&format!(
1311 r#"<tr>
1312 <td><span class="wb-chip wb-chip--{chip}">{verdict}</span></td>
1313 <td><a href="/findings/{vf}"><code>{vf_short}</code></a></td>
1314 <td>{claim} / {grade}</td>
1315 <td>{text}</td>
1316</tr>"#,
1317 chip = chip,
1318 verdict = match e.verdict {
1319 Identifiability::Underidentified => "underidentified",
1320 Identifiability::Conditional => "conditional",
1321 _ => "—",
1322 },
1323 vf = escape_html(&e.finding_id),
1324 vf_short = escape_html(&e.finding_id),
1325 claim = escape_html(&claim),
1326 grade = escape_html(&grade),
1327 text = escape_html(&text),
1328 ));
1329 }
1330
1331 let stats_html = format!(
1332 r#"<div class="wb-stats">
1333 <div><div class="wb-stat__num">{}</div><div class="wb-stat__label">identified</div></div>
1334 <div><div class="wb-stat__num">{}</div><div class="wb-stat__label">conditional</div></div>
1335 <div><div class="wb-stat__num">{}</div><div class="wb-stat__label">underidentified</div></div>
1336 <div><div class="wb-stat__num">{}</div><div class="wb-stat__label">underdetermined</div></div>
1337</div>"#,
1338 summary.identified, summary.conditional, summary.underidentified, summary.underdetermined,
1339 );
1340
1341 let body = if entries.is_empty() {
1342 format!(
1343 "{stats_html}<div class=\"wb-card\"><p>No reviewer-attention items. Audit clean.</p></div>"
1344 )
1345 } else {
1346 format!(
1347 r#"{stats_html}
1348<table class="wb-table">
1349 <thead>
1350 <tr><th>verdict</th><th>finding</th><th>claim/grade</th><th>assertion</th></tr>
1351 </thead>
1352 <tbody>
1353{rows}
1354 </tbody>
1355</table>"#
1356 )
1357 };
1358
1359 Html(shell(
1360 "audit",
1361 "Causal audit",
1362 "Workbench",
1363 "Identifiability audit",
1364 &body,
1365 ))
1366 .into_response()
1367}
1368
1369async fn page_bridges(State(state): State<AppState>) -> Response {
1370 let bridges = list_bridges(&state.repo_path);
1371
1372 if bridges.is_empty() {
1373 let body = r#"<div class="wb-card">
1374 <p>No bridges yet. Derive one with:</p>
1375 <p><code>vela bridges derive <frontier_a> <frontier_b></code></p>
1376</div>"#;
1377 return Html(shell("bridges", "Bridges", "Workbench", "No bridges", body)).into_response();
1378 }
1379
1380 let mut cards = String::new();
1381 for b in &bridges {
1382 let chip = match b.status {
1383 BridgeStatus::Confirmed => "ok",
1384 BridgeStatus::Refuted => "lost",
1385 BridgeStatus::Derived => "warn",
1386 };
1387 let chip_label = match b.status {
1388 BridgeStatus::Confirmed => "confirmed",
1389 BridgeStatus::Refuted => "refuted",
1390 BridgeStatus::Derived => "derived",
1391 };
1392
1393 let mut refs_html = String::new();
1394 for r in b.finding_refs.iter().take(6) {
1395 let txt: String = r.assertion_text.chars().take(110).collect();
1396 refs_html.push_str(&format!(
1397 "<p>· <code>[{}]</code> <code>{}</code> conf {:.2} — {}</p>",
1398 escape_html(&r.frontier),
1399 escape_html(&r.finding_id),
1400 r.confidence,
1401 escape_html(&txt),
1402 ));
1403 }
1404 if b.finding_refs.len() > 6 {
1405 refs_html.push_str(&format!("<p>… and {} more</p>", b.finding_refs.len() - 6));
1406 }
1407
1408 let actions_html = match b.status {
1409 BridgeStatus::Derived => format!(
1410 r#"<div class="wb-actions">
1411 <form method="post" action="/bridges/{id}/confirm"><button type="submit">Confirm</button></form>
1412 <form method="post" action="/bridges/{id}/refute"><button type="submit">Refute</button></form>
1413</div>"#,
1414 id = escape_html(&b.id),
1415 ),
1416 BridgeStatus::Confirmed => format!(
1417 r#"<div class="wb-actions">
1418 <form method="post" action="/bridges/{id}/refute"><button type="submit">Mark refuted</button></form>
1419</div>"#,
1420 id = escape_html(&b.id),
1421 ),
1422 BridgeStatus::Refuted => format!(
1423 r#"<div class="wb-actions">
1424 <form method="post" action="/bridges/{id}/confirm"><button type="submit">Re-confirm</button></form>
1425</div>"#,
1426 id = escape_html(&b.id),
1427 ),
1428 };
1429
1430 let tension_html = b.tension.as_deref().map_or(String::new(), |t| {
1431 format!(
1432 r#"<p style="color:#872c2c;font-style:italic;">tension: {}</p>"#,
1433 escape_html(t)
1434 )
1435 });
1436
1437 cards.push_str(&format!(
1438 r#"<div class="wb-card">
1439 <h3><span class="wb-chip wb-chip--{chip}">{chip_label}</span><code>{id}</code> · {entity}</h3>
1440 <p><strong>frontiers:</strong> {frontiers} · <strong>findings:</strong> {n_refs}</p>
1441 {tension_html}
1442 {refs_html}
1443 {actions_html}
1444</div>"#,
1445 chip = chip,
1446 chip_label = chip_label,
1447 id = escape_html(&b.id),
1448 entity = escape_html(&b.entity_name),
1449 frontiers = escape_html(&b.frontiers.join(" ↔ ")),
1450 n_refs = b.finding_refs.len(),
1451 ));
1452 }
1453
1454 let body = cards;
1455
1456 Html(shell(
1457 "bridges",
1458 "Bridges",
1459 "Workbench",
1460 &format!("{} cross-frontier bridge(s)", bridges.len()),
1461 &body,
1462 ))
1463 .into_response()
1464}
1465
1466async fn page_negative_results(State(state): State<AppState>) -> Response {
1469 let project = match repo::load_from_path(&state.repo_path) {
1470 Ok(p) => p,
1471 Err(e) => return error_page("nulls", "Could not load frontier", &e),
1472 };
1473
1474 if project.negative_results.is_empty() {
1475 let body = r#"<div class="wb-card">
1476 <p>No NegativeResults deposited yet. Add one with:</p>
1477 <p><code>vela negative-result-add <frontier> --kind exploratory \
1478 --reagent <...> --observation <...> --attempts <n> \
1479 --deposited-by <actor> --reason <...> \
1480 --conditions-text <...> --source-title <...></code></p>
1481 <p>Or for a registered-trial null:</p>
1482 <p><code>vela negative-result-add <frontier> --kind registered_trial \
1483 --endpoint <...> --intervention <...> --comparator <...> \
1484 --population <...> --n-enrolled <n> --power <p> \
1485 --ci-lower <l> --ci-upper <u> ...</code></p>
1486</div>"#;
1487 return Html(shell(
1488 "nulls",
1489 "Negative Results",
1490 "Workbench",
1491 "No NegativeResults",
1492 body,
1493 ))
1494 .into_response();
1495 }
1496
1497 let mut trial_count = 0usize;
1498 let mut exploratory_count = 0usize;
1499 let mut informative_count = 0usize;
1500 for nr in &project.negative_results {
1501 match &nr.kind {
1502 crate::bundle::NegativeResultKind::RegisteredTrial { .. } => trial_count += 1,
1503 crate::bundle::NegativeResultKind::Exploratory { .. } => exploratory_count += 1,
1504 }
1505 if nr.is_informative_trial_null() == Some(true) {
1506 informative_count += 1;
1507 }
1508 }
1509
1510 let stats_html = format!(
1511 r#"<div class="wb-stats">
1512 <div><div class="wb-stat__num">{total}</div><div class="wb-stat__label">total</div></div>
1513 <div><div class="wb-stat__num">{trial}</div><div class="wb-stat__label">trial</div></div>
1514 <div><div class="wb-stat__num">{expl}</div><div class="wb-stat__label">exploratory</div></div>
1515 <div><div class="wb-stat__num">{inf}</div><div class="wb-stat__label">informative</div></div>
1516</div>"#,
1517 total = project.negative_results.len(),
1518 trial = trial_count,
1519 expl = exploratory_count,
1520 inf = informative_count,
1521 );
1522
1523 let mut cards = String::new();
1524 for nr in &project.negative_results {
1525 let (chip_kind, chip_label, kind_body) = match &nr.kind {
1526 crate::bundle::NegativeResultKind::RegisteredTrial {
1527 endpoint,
1528 intervention,
1529 comparator,
1530 population,
1531 n_enrolled,
1532 power,
1533 effect_size_ci,
1534 effect_size_threshold,
1535 registry_id,
1536 } => {
1537 let informative = nr.is_informative_trial_null();
1538 let inf_chip = match informative {
1539 Some(true) => r#"<span class="wb-chip wb-chip--ok">informative</span>"#,
1540 Some(false) => r#"<span class="wb-chip wb-chip--warn">uninformative</span>"#,
1541 None => "",
1542 };
1543 let mcid = effect_size_threshold
1544 .map(|t| format!("MCID ±{t:.3}"))
1545 .unwrap_or_else(|| "no MCID declared".to_string());
1546 let registry = registry_id
1547 .as_deref()
1548 .map(|r| format!(" · <code>{}</code>", escape_html(r)))
1549 .unwrap_or_default();
1550 (
1551 "warn",
1552 "registered_trial",
1553 format!(
1554 "<p>{inf_chip}<strong>{ep}</strong>{reg}</p>\
1555 <p>{int} vs {cmp} · {pop}</p>\
1556 <p>n={n} · power {pw:.2} · CI [{lo:.3}, {hi:.3}] · {mcid}</p>",
1557 ep = escape_html(endpoint),
1558 reg = registry,
1559 int = escape_html(intervention),
1560 cmp = escape_html(comparator),
1561 pop = escape_html(population),
1562 n = n_enrolled,
1563 pw = power,
1564 lo = effect_size_ci.0,
1565 hi = effect_size_ci.1,
1566 ),
1567 )
1568 }
1569 crate::bundle::NegativeResultKind::Exploratory {
1570 reagent,
1571 observation,
1572 attempts,
1573 } => (
1574 "warn",
1575 "exploratory",
1576 format!(
1577 "<p><strong>reagent:</strong> {r}</p>\
1578 <p><strong>observation:</strong> {o}</p>\
1579 <p><strong>attempts:</strong> {a}</p>",
1580 r = escape_html(reagent),
1581 o = escape_html(observation),
1582 a = attempts,
1583 ),
1584 ),
1585 };
1586
1587 let retracted_chip = if nr.retracted {
1588 r#"<span class="wb-chip wb-chip--lost">retracted</span>"#
1589 } else {
1590 ""
1591 };
1592 let review_chip = nr
1593 .review_state
1594 .as_ref()
1595 .map(|s| {
1596 let (c, label) = match s {
1597 crate::bundle::ReviewState::Accepted => ("ok", "accepted"),
1598 crate::bundle::ReviewState::Contested => ("warn", "contested"),
1599 crate::bundle::ReviewState::NeedsRevision => ("warn", "needs revision"),
1600 crate::bundle::ReviewState::Rejected => ("lost", "rejected"),
1601 };
1602 format!(r#"<span class="wb-chip wb-chip--{c}">{label}</span>"#)
1603 })
1604 .unwrap_or_default();
1605 let tier_chip = if !matches!(nr.access_tier, crate::access_tier::AccessTier::Public) {
1606 format!(
1607 r#"<span class="wb-chip wb-chip--lost">{}</span>"#,
1608 nr.access_tier.canonical()
1609 )
1610 } else {
1611 String::new()
1612 };
1613
1614 let targets_html = if nr.target_findings.is_empty() {
1615 String::new()
1616 } else {
1617 let links: Vec<String> = nr
1618 .target_findings
1619 .iter()
1620 .map(|t| {
1621 format!(
1622 r#"<a href="/findings/{t}"><code>{t}</code></a>"#,
1623 t = escape_html(t)
1624 )
1625 })
1626 .collect();
1627 format!(
1628 "<p><strong>bears against:</strong> {}</p>",
1629 links.join(" · ")
1630 )
1631 };
1632
1633 let notes_html = if nr.notes.trim().is_empty() {
1634 String::new()
1635 } else {
1636 format!(
1637 "<p style=\"color:var(--ink-2,#6b665d);font-style:italic;\">{}</p>",
1638 escape_html(&nr.notes)
1639 )
1640 };
1641
1642 cards.push_str(&format!(
1643 r#"<div class="wb-card">
1644 <h3><span class="wb-chip wb-chip--{chip_kind}">{chip_label}</span>{retracted_chip}{review_chip}{tier_chip}<code>{id}</code></h3>
1645 {kind_body}
1646 {targets_html}
1647 {notes_html}
1648 <p style="font-size:0.78rem;color:var(--ink-3,#a09a8d);">deposited by {actor} · {created}</p>
1649</div>"#,
1650 id = escape_html(&nr.id),
1651 actor = escape_html(&nr.deposited_by),
1652 created = escape_html(&nr.created),
1653 ));
1654 }
1655
1656 let body = format!("{stats_html}{cards}");
1657 Html(shell(
1658 "nulls",
1659 "Negative Results",
1660 "Workbench",
1661 &format!("{} negative result(s)", project.negative_results.len()),
1662 &body,
1663 ))
1664 .into_response()
1665}
1666
1667async fn page_trajectories(State(state): State<AppState>) -> Response {
1670 let project = match repo::load_from_path(&state.repo_path) {
1671 Ok(p) => p,
1672 Err(e) => return error_page("trajectories", "Could not load frontier", &e),
1673 };
1674
1675 if project.trajectories.is_empty() {
1676 let body = r#"<div class="wb-card">
1677 <p>No trajectories deposited yet. Open one with:</p>
1678 <p><code>vela trajectory-create <frontier> --deposited-by <actor> \
1679 --reason <...> [--target vf_…]* [--notes <...>]</code></p>
1680 <p>Then append steps:</p>
1681 <p><code>vela trajectory-step <frontier> <vtr_id> \
1682 --kind hypothesis|tried|ruled_out|observed|refined \
1683 --description <...> --actor <id> --reason <...></code></p>
1684</div>"#;
1685 return Html(shell(
1686 "trajectories",
1687 "Trajectories",
1688 "Workbench",
1689 "No trajectories",
1690 body,
1691 ))
1692 .into_response();
1693 }
1694
1695 let total_steps: usize = project.trajectories.iter().map(|t| t.steps.len()).sum();
1696 let stats_html = format!(
1697 r#"<div class="wb-stats">
1698 <div><div class="wb-stat__num">{}</div><div class="wb-stat__label">trajectories</div></div>
1699 <div><div class="wb-stat__num">{}</div><div class="wb-stat__label">total steps</div></div>
1700 <div><div class="wb-stat__num">{}</div><div class="wb-stat__label">retracted</div></div>
1701 <div><div class="wb-stat__num">{}</div><div class="wb-stat__label">reviewed</div></div>
1702</div>"#,
1703 project.trajectories.len(),
1704 total_steps,
1705 project.trajectories.iter().filter(|t| t.retracted).count(),
1706 project
1707 .trajectories
1708 .iter()
1709 .filter(|t| t.review_state.is_some())
1710 .count(),
1711 );
1712
1713 let mut cards = String::new();
1714 for t in &project.trajectories {
1715 let retracted_chip = if t.retracted {
1716 r#"<span class="wb-chip wb-chip--lost">retracted</span>"#
1717 } else {
1718 ""
1719 };
1720 let review_chip = t
1721 .review_state
1722 .as_ref()
1723 .map(|s| {
1724 let (c, label) = match s {
1725 crate::bundle::ReviewState::Accepted => ("ok", "accepted"),
1726 crate::bundle::ReviewState::Contested => ("warn", "contested"),
1727 crate::bundle::ReviewState::NeedsRevision => ("warn", "needs revision"),
1728 crate::bundle::ReviewState::Rejected => ("lost", "rejected"),
1729 };
1730 format!(r#"<span class="wb-chip wb-chip--{c}">{label}</span>"#)
1731 })
1732 .unwrap_or_default();
1733 let tier_chip = if !matches!(t.access_tier, crate::access_tier::AccessTier::Public) {
1734 format!(
1735 r#"<span class="wb-chip wb-chip--lost">{}</span>"#,
1736 t.access_tier.canonical()
1737 )
1738 } else {
1739 String::new()
1740 };
1741
1742 let targets_html = if t.target_findings.is_empty() {
1743 String::new()
1744 } else {
1745 let links: Vec<String> = t
1746 .target_findings
1747 .iter()
1748 .map(|f| {
1749 format!(
1750 r#"<a href="/findings/{f}"><code>{f}</code></a>"#,
1751 f = escape_html(f)
1752 )
1753 })
1754 .collect();
1755 format!("<p><strong>targets:</strong> {}</p>", links.join(" · "))
1756 };
1757
1758 let mut steps_html = String::new();
1759 for (i, step) in t.steps.iter().enumerate() {
1760 let (chip_kind, kind_label) = match step.kind {
1761 crate::bundle::TrajectoryStepKind::Hypothesis => ("warn", "hypothesis"),
1762 crate::bundle::TrajectoryStepKind::Tried => ("warn", "tried"),
1763 crate::bundle::TrajectoryStepKind::RuledOut => ("lost", "ruled out"),
1764 crate::bundle::TrajectoryStepKind::Observed => ("ok", "observed"),
1765 crate::bundle::TrajectoryStepKind::Refined => ("ok", "refined"),
1766 };
1767 steps_html.push_str(&format!(
1768 r#"<div style="border-left:2px solid var(--rule-2,#d8d4cc);padding:0.4rem 0.7rem;margin:0.3rem 0;">
1769 <p style="margin:0 0 0.2rem 0;"><span class="wb-chip wb-chip--{chip_kind}">{i:02} · {kind_label}</span></p>
1770 <p style="margin:0 0 0.2rem 0;">{desc}</p>
1771 <p style="font-size:0.74rem;color:var(--ink-3,#a09a8d);margin:0;">{actor} · {at}</p>
1772</div>"#,
1773 i = i + 1,
1774 desc = escape_html(&step.description),
1775 actor = escape_html(&step.actor),
1776 at = escape_html(&step.at),
1777 ));
1778 }
1779 if t.steps.is_empty() {
1780 steps_html.push_str(
1781 r#"<p style="color:var(--ink-3,#a09a8d);font-style:italic;">No steps yet.</p>"#,
1782 );
1783 }
1784
1785 let notes_html = if t.notes.trim().is_empty() {
1786 String::new()
1787 } else {
1788 format!(
1789 "<p style=\"color:var(--ink-2,#6b665d);font-style:italic;\">{}</p>",
1790 escape_html(&t.notes)
1791 )
1792 };
1793
1794 cards.push_str(&format!(
1795 r#"<div class="wb-card">
1796 <h3>{retracted_chip}{review_chip}{tier_chip}<code>{id}</code> · {n_steps} step(s)</h3>
1797 {targets_html}
1798 {notes_html}
1799 {steps_html}
1800 <p style="font-size:0.78rem;color:var(--ink-3,#a09a8d);">opened by {actor} · {created}</p>
1801</div>"#,
1802 id = escape_html(&t.id),
1803 n_steps = t.steps.len(),
1804 actor = escape_html(&t.deposited_by),
1805 created = escape_html(&t.created),
1806 ));
1807 }
1808
1809 let body = format!("{stats_html}{cards}");
1810 Html(shell(
1811 "trajectories",
1812 "Trajectories",
1813 "Workbench",
1814 &format!("{} trajector(y/ies)", project.trajectories.len()),
1815 &body,
1816 ))
1817 .into_response()
1818}
1819
1820async fn page_tiers(State(state): State<AppState>) -> Response {
1823 let project = match repo::load_from_path(&state.repo_path) {
1824 Ok(p) => p,
1825 Err(e) => return error_page("tiers", "Could not load frontier", &e),
1826 };
1827
1828 let count_findings = |tier: crate::access_tier::AccessTier| {
1829 project
1830 .findings
1831 .iter()
1832 .filter(|f| f.access_tier == tier)
1833 .count()
1834 };
1835 let count_nrs = |tier: crate::access_tier::AccessTier| {
1836 project
1837 .negative_results
1838 .iter()
1839 .filter(|n| n.access_tier == tier)
1840 .count()
1841 };
1842 let count_trajs = |tier: crate::access_tier::AccessTier| {
1843 project
1844 .trajectories
1845 .iter()
1846 .filter(|t| t.access_tier == tier)
1847 .count()
1848 };
1849 let public_total = count_findings(crate::access_tier::AccessTier::Public)
1850 + count_nrs(crate::access_tier::AccessTier::Public)
1851 + count_trajs(crate::access_tier::AccessTier::Public);
1852 let restricted_total = count_findings(crate::access_tier::AccessTier::Restricted)
1853 + count_nrs(crate::access_tier::AccessTier::Restricted)
1854 + count_trajs(crate::access_tier::AccessTier::Restricted);
1855 let classified_total = count_findings(crate::access_tier::AccessTier::Classified)
1856 + count_nrs(crate::access_tier::AccessTier::Classified)
1857 + count_trajs(crate::access_tier::AccessTier::Classified);
1858
1859 let stats_html = format!(
1860 r#"<div class="wb-stats">
1861 <div><div class="wb-stat__num">{public_total}</div><div class="wb-stat__label">public</div></div>
1862 <div><div class="wb-stat__num">{restricted_total}</div><div class="wb-stat__label">restricted</div></div>
1863 <div><div class="wb-stat__num">{classified_total}</div><div class="wb-stat__label">classified</div></div>
1864 <div><div class="wb-stat__num">{cleared}</div><div class="wb-stat__label">cleared actors</div></div>
1865</div>"#,
1866 cleared = project
1867 .actors
1868 .iter()
1869 .filter(|a| a.access_clearance.is_some())
1870 .count(),
1871 );
1872
1873 let mut tier_events: Vec<&crate::events::StateEvent> = project
1874 .events
1875 .iter()
1876 .filter(|e| e.kind == "tier.set")
1877 .collect();
1878 tier_events.sort_by(|a, b| b.timestamp.cmp(&a.timestamp));
1879
1880 let events_html = if tier_events.is_empty() {
1881 r#"<div class="wb-card"><p>No <code>tier.set</code> events yet. Reclassify with:</p>
1882<p><code>vela tier-set <frontier> --object-type finding|negative_result|trajectory \
1883 --object-id <id> --tier public|restricted|classified \
1884 --actor <id> --reason <...></code></p></div>"#
1885 .to_string()
1886 } else {
1887 let mut rows = String::new();
1888 for e in &tier_events {
1889 let prev_tier = e
1890 .payload
1891 .get("previous_tier")
1892 .and_then(|v| v.as_str())
1893 .unwrap_or("public");
1894 let new_tier = e
1895 .payload
1896 .get("new_tier")
1897 .and_then(|v| v.as_str())
1898 .unwrap_or("?");
1899 let chip_kind = match new_tier {
1900 "public" => "ok",
1901 "restricted" => "warn",
1902 "classified" => "lost",
1903 _ => "warn",
1904 };
1905 rows.push_str(&format!(
1906 r#"<tr>
1907 <td><code>{ts}</code></td>
1908 <td><span class="wb-chip wb-chip--ok">{prev}</span> → <span class="wb-chip wb-chip--{chip_kind}">{new}</span></td>
1909 <td><code>{ot}</code> <code>{oi}</code></td>
1910 <td>{actor}</td>
1911 <td>{reason}</td>
1912</tr>"#,
1913 ts = escape_html(&e.timestamp),
1914 prev = escape_html(prev_tier),
1915 new = escape_html(new_tier),
1916 ot = escape_html(&e.target.r#type),
1917 oi = escape_html(&e.target.id),
1918 actor = escape_html(&e.actor.id),
1919 reason = escape_html(&e.reason),
1920 ));
1921 }
1922 format!(
1923 r#"<table class="wb-table">
1924 <thead><tr><th>at</th><th>change</th><th>object</th><th>actor</th><th>reason</th></tr></thead>
1925 <tbody>{rows}</tbody>
1926</table>"#
1927 )
1928 };
1929
1930 let breakdown_html = format!(
1931 r#"<div class="wb-card">
1932 <h3>Per-collection breakdown</h3>
1933 <table class="wb-table">
1934 <thead><tr><th>collection</th><th>public</th><th>restricted</th><th>classified</th></tr></thead>
1935 <tbody>
1936 <tr><td>findings</td><td>{fp}</td><td>{fr}</td><td>{fc}</td></tr>
1937 <tr><td>negative_results</td><td>{np}</td><td>{nr}</td><td>{nc}</td></tr>
1938 <tr><td>trajectories</td><td>{tp}</td><td>{tr}</td><td>{tc}</td></tr>
1939 </tbody>
1940 </table>
1941</div>"#,
1942 fp = count_findings(crate::access_tier::AccessTier::Public),
1943 fr = count_findings(crate::access_tier::AccessTier::Restricted),
1944 fc = count_findings(crate::access_tier::AccessTier::Classified),
1945 np = count_nrs(crate::access_tier::AccessTier::Public),
1946 nr = count_nrs(crate::access_tier::AccessTier::Restricted),
1947 nc = count_nrs(crate::access_tier::AccessTier::Classified),
1948 tp = count_trajs(crate::access_tier::AccessTier::Public),
1949 tr = count_trajs(crate::access_tier::AccessTier::Restricted),
1950 tc = count_trajs(crate::access_tier::AccessTier::Classified),
1951 );
1952
1953 let body = format!("{stats_html}{breakdown_html}{events_html}");
1954 Html(shell(
1955 "tiers",
1956 "Access tiers",
1957 "Workbench",
1958 "Dual-use access tiers",
1959 &body,
1960 ))
1961 .into_response()
1962}
1963
1964async fn page_constellation(State(state): State<AppState>) -> Response {
1967 let project = match repo::load_from_path(&state.repo_path) {
1968 Ok(p) => p,
1969 Err(e) => return error_page("constellation", "Could not load frontier", &e),
1970 };
1971
1972 let n_findings = project.findings.len();
1973 let n_links: usize = project.findings.iter().map(|f| f.links.len()).sum();
1974 let n_cascade = project
1975 .events
1976 .iter()
1977 .filter(|e| e.kind == "finding.dependency_invalidated" || e.kind == "finding.cascade_fired")
1978 .count();
1979 let n_retracted = project
1980 .findings
1981 .iter()
1982 .filter(|f| f.flags.retracted)
1983 .count();
1984
1985 let stats_html = format!(
1986 r#"<div class="wb-stats">
1987 <div><div class="wb-stat__num">{n_findings}</div><div class="wb-stat__label">findings</div></div>
1988 <div><div class="wb-stat__num">{n_links}</div><div class="wb-stat__label">links</div></div>
1989 <div><div class="wb-stat__num">{n_cascade}</div><div class="wb-stat__label">cascade events</div></div>
1990 <div><div class="wb-stat__num">{n_retracted}</div><div class="wb-stat__label">retracted</div></div>
1991</div>"#
1992 );
1993
1994 let svg_html = render_constellation_svg(&project);
1995
1996 let panel_html = r#"<aside class="vc-panel" data-vc-panel hidden>
1997 <header class="vc-panel__head">
1998 <span class="vc-panel__eyebrow">Selected finding</span>
1999 <h3 class="vc-panel__title" data-vc-panel-title>—</h3>
2000 <p class="vc-panel__id"><code data-vc-panel-id>—</code></p>
2001 </header>
2002 <p class="vc-panel__claim" data-vc-panel-claim>Click a node in the constellation to inspect it.</p>
2003 <dl class="vc-panel__meta">
2004 <div><dt>confidence</dt><dd data-vc-panel-conf>—</dd></div>
2005 <div><dt>state</dt><dd data-vc-panel-state>—</dd></div>
2006 <div><dt>dependents</dt><dd data-vc-panel-deps-in>—</dd></div>
2007 <div><dt>dependencies</dt><dd data-vc-panel-deps-out>—</dd></div>
2008 </dl>
2009 <form class="vc-panel__cascade" data-vc-cascade-form>
2010 <label for="vc-cascade-conf">Fire correction — drop confidence to:</label>
2011 <input id="vc-cascade-conf" type="range" min="0" max="100" value="40" step="1" data-vc-cascade-slider>
2012 <output data-vc-cascade-readout>0.40</output>
2013 <button type="submit">Apply correction & cascade</button>
2014 <p class="vc-panel__note" data-vc-cascade-status></p>
2015 </form>
2016 <p class="vc-panel__open"><a href="" data-vc-panel-open>→ open detail page</a></p>
2017</aside>"#;
2018
2019 let body = format!(
2020 r#"{stats_html}
2021<p class="wb-eyebrow" style="margin-top:0.4rem;">Click a node to focus + open the inspector. Drag the slider, hit
2022"Apply correction" to drop the finding's confidence — the cascade fires
2023through <code>supports</code> and <code>depends</code> edges live, and any
2024flagged dependents pulse gold.</p>
2025<div class="vc-stage">
2026 {svg_html}
2027 {panel_html}
2028</div>
2029<style>{vc_css}</style>
2030<script>{vc_js}</script>"#,
2031 vc_css = CONSTELLATION_CSS,
2032 vc_js = CONSTELLATION_JS,
2033 );
2034
2035 Html(shell(
2036 "constellation",
2037 "Constellation",
2038 "Workbench",
2039 "Live constellation",
2040 &body,
2041 ))
2042 .into_response()
2043}
2044
2045#[derive(Deserialize)]
2046struct PropagateForm {
2047 new_score: f64,
2048 #[serde(default)]
2049 reason: Option<String>,
2050 #[serde(default)]
2051 reviewer: Option<String>,
2052}
2053
2054#[derive(serde::Serialize)]
2055struct PropagateResponse {
2056 ok: bool,
2057 finding_id: String,
2058 new_confidence: f64,
2059 affected: Vec<String>,
2060 cascade_events: usize,
2061 message: String,
2062}
2063
2064async fn post_api_propagate_confidence(
2065 AxumPath(vf_id): AxumPath<String>,
2066 State(state): State<AppState>,
2067 Form(body): Form<PropagateForm>,
2068) -> Response {
2069 let new_score = body.new_score.clamp(0.0, 1.0);
2070 let reviewer = body
2071 .reviewer
2072 .filter(|s| !s.is_empty())
2073 .unwrap_or_else(|| "reviewer:workbench".to_string());
2074 let reason = body
2075 .reason
2076 .filter(|s| !s.is_empty())
2077 .unwrap_or_else(|| "Workbench cascade fire".to_string());
2078
2079 let project_before = match repo::load_from_path(&state.repo_path) {
2080 Ok(p) => p,
2081 Err(e) => {
2082 return (
2083 StatusCode::INTERNAL_SERVER_ERROR,
2084 Json(PropagateResponse {
2085 ok: false,
2086 finding_id: vf_id.clone(),
2087 new_confidence: new_score,
2088 affected: Vec::new(),
2089 cascade_events: 0,
2090 message: format!("load failed: {e}"),
2091 }),
2092 )
2093 .into_response();
2094 }
2095 };
2096 let cascade_before = project_before
2097 .events
2098 .iter()
2099 .filter(|e| e.kind == "finding.dependency_invalidated")
2100 .count();
2101
2102 let opts = ReviseOptions {
2103 confidence: new_score,
2104 reason,
2105 reviewer,
2106 };
2107 let result = state::revise_confidence(&state.repo_path, &vf_id, opts, true);
2108 let report = match result {
2109 Ok(r) => r,
2110 Err(e) => {
2111 return (
2112 StatusCode::BAD_REQUEST,
2113 Json(PropagateResponse {
2114 ok: false,
2115 finding_id: vf_id.clone(),
2116 new_confidence: new_score,
2117 affected: Vec::new(),
2118 cascade_events: 0,
2119 message: format!("revise failed: {e}"),
2120 }),
2121 )
2122 .into_response();
2123 }
2124 };
2125
2126 let project_after = match repo::load_from_path(&state.repo_path) {
2127 Ok(p) => p,
2128 Err(e) => {
2129 return (
2130 StatusCode::INTERNAL_SERVER_ERROR,
2131 Json(PropagateResponse {
2132 ok: false,
2133 finding_id: vf_id.clone(),
2134 new_confidence: new_score,
2135 affected: Vec::new(),
2136 cascade_events: 0,
2137 message: format!("post-load failed: {e}"),
2138 }),
2139 )
2140 .into_response();
2141 }
2142 };
2143 let cascade_events: Vec<&crate::events::StateEvent> = project_after
2144 .events
2145 .iter()
2146 .filter(|e| e.kind == "finding.dependency_invalidated")
2147 .collect();
2148 let new_cascade = cascade_events.len().saturating_sub(cascade_before);
2149 let mut affected: Vec<String> = cascade_events
2150 .iter()
2151 .rev()
2152 .take(new_cascade)
2153 .filter_map(|e| {
2154 e.payload
2155 .get("affected_finding")
2156 .and_then(|v| v.as_str())
2157 .map(String::from)
2158 .or_else(|| Some(e.target.id.clone()))
2159 })
2160 .collect();
2161 affected.sort();
2162 affected.dedup();
2163
2164 (
2165 StatusCode::OK,
2166 Json(PropagateResponse {
2167 ok: true,
2168 finding_id: if report.finding_id.is_empty() {
2169 vf_id
2170 } else {
2171 report.finding_id
2172 },
2173 new_confidence: new_score,
2174 affected,
2175 cascade_events: new_cascade,
2176 message: report.message,
2177 }),
2178 )
2179 .into_response()
2180}
2181
2182fn finding_state_classes(
2183 b: &FindingBundle,
2184 replications: &[Replication],
2185) -> (&'static str, &'static str) {
2186 use crate::bundle::ReviewState;
2187 if b.flags.retracted {
2188 return ("retracted", "lost");
2189 }
2190 if b.flags.gap || b.flags.negative_space {
2191 return ("gap", "stale");
2192 }
2193 if let Some(state) = &b.flags.review_state {
2194 match state {
2195 ReviewState::Contested => return ("contested", "warn"),
2196 ReviewState::NeedsRevision => return ("contested", "warn"),
2197 ReviewState::Rejected => return ("retracted", "lost"),
2198 ReviewState::Accepted => {
2199 if is_replicated_for_constellation(b, replications) {
2200 return ("replicated", "ok");
2201 }
2202 return ("supported", "ok");
2203 }
2204 }
2205 }
2206 if b.flags.contested {
2207 return ("contested", "warn");
2208 }
2209 if is_replicated_for_constellation(b, replications) {
2210 return ("replicated", "ok");
2211 }
2212 ("supported", "ok")
2213}
2214
2215fn is_replicated_for_constellation(b: &FindingBundle, replications: &[Replication]) -> bool {
2216 let mut has_record = false;
2217 let mut has_success = false;
2218 for r in replications {
2219 if r.target_finding == b.id {
2220 has_record = true;
2221 if r.outcome == "replicated" {
2222 has_success = true;
2223 }
2224 }
2225 }
2226 if has_record {
2227 has_success
2228 } else {
2229 b.evidence.replicated
2230 }
2231}
2232
2233fn render_constellation_svg(p: &Project) -> String {
2234 if p.findings.is_empty() {
2235 return String::from(
2236 r#"<p class="vc-empty">No findings yet — deposit one with <code>vela finding add</code>.</p>"#,
2237 );
2238 }
2239 let n = p.findings.len();
2240 let view_w: i32 = 720;
2241 let view_h: i32 = 380;
2242 let cx = view_w as f64 / 2.0;
2243 let cy = view_h as f64 / 2.0;
2244 let ring_r = (cx.min(cy) - 60.0).max(80.0);
2245
2246 let pos: std::collections::HashMap<&str, (f64, f64)> = p
2247 .findings
2248 .iter()
2249 .enumerate()
2250 .map(|(i, b)| {
2251 let angle = (i as f64 / n as f64) * std::f64::consts::TAU - std::f64::consts::FRAC_PI_2;
2252 let x = cx + ring_r * angle.cos();
2253 let y = cy + ring_r * angle.sin();
2254 (b.id.as_str(), (x, y))
2255 })
2256 .collect();
2257
2258 let mut deps_out: std::collections::HashMap<&str, u32> = std::collections::HashMap::new();
2259 let mut deps_in: std::collections::HashMap<&str, u32> = std::collections::HashMap::new();
2260 for b in &p.findings {
2261 let from = b.id.as_str();
2262 for link in &b.links {
2263 *deps_out.entry(from).or_default() += 1;
2264 if pos.contains_key(link.target.as_str()) {
2265 *deps_in.entry(link.target.as_str()).or_default() += 1;
2266 }
2267 }
2268 }
2269
2270 let mut edges = String::new();
2271 for b in &p.findings {
2272 let Some(&(x1, y1)) = pos.get(b.id.as_str()) else {
2273 continue;
2274 };
2275 let from = escape_html(&b.id);
2276 for link in &b.links {
2277 if let Some(&(x2, y2)) = pos.get(link.target.as_str()) {
2278 let mx = (x1 + x2) / 2.0;
2279 let my = (y1 + y2) / 2.0;
2280 let pull = 0.45;
2281 let qx = cx + (mx - cx) * pull;
2282 let qy = cy + (my - cy) * pull;
2283 let to = escape_html(&link.target);
2284 let lt = escape_html(&link.link_type);
2285 edges.push_str(&format!(
2286 r##"<path class="vc-edge" data-from="{from}" data-to="{to}" data-link-type="{lt}" d="M {x1:.1} {y1:.1} Q {qx:.1} {qy:.1} {x2:.1} {y2:.1}"/>"##
2287 ));
2288 } else {
2289 let dx = x1 - cx;
2290 let dy = y1 - cy;
2291 let mag = (dx * dx + dy * dy).sqrt().max(1e-6);
2292 let conf = b.confidence.score.clamp(0.0, 1.0);
2293 let outward = 18.0 + conf * 22.0;
2294 let xt = x1 + (dx / mag) * outward;
2295 let yt = y1 + (dy / mag) * outward;
2296 edges.push_str(&format!(
2297 r##"<path class="vc-edge vc-edge--cross" data-from="{from}" data-to="cross" d="M {x1:.1} {y1:.1} L {xt:.1} {yt:.1}"/>"##
2298 ));
2299 }
2300 }
2301 }
2302
2303 let mut nodes = String::new();
2304 for b in &p.findings {
2305 let (x, y) = pos[b.id.as_str()];
2306 let (label, state_class) = finding_state_classes(b, &p.replications);
2307 let r = 4.0 + b.confidence.score.clamp(0.0, 1.0) * 5.0;
2308 let live_class = if label == "replicated" {
2309 " vc-node--live"
2310 } else {
2311 ""
2312 };
2313 let vf = escape_html(&b.id);
2314 let claim = escape_html(&b.assertion.text);
2315 let n_out = deps_out.get(b.id.as_str()).copied().unwrap_or(0);
2316 let n_in = deps_in.get(b.id.as_str()).copied().unwrap_or(0);
2317 let conf = b.confidence.score;
2318 let href = format!("/findings/{}", escape_html(&b.id));
2319 nodes.push_str(&format!(
2320 r#"<a class="vc-node{live_class}" href="{href}" data-vf="{vf}" data-state="{label}" data-claim="{claim}" data-conf="{conf:.3}" data-deps-out="{n_out}" data-deps-in="{n_in}">
2321 <circle class="vc-glow" cx="{x:.1}" cy="{y:.1}" r="{rg:.1}"/>
2322 <circle class="vc-dot" cx="{x:.1}" cy="{y:.1}" r="{r:.1}" style="fill:var(--state-{state_class});"/>
2323 </a>"#,
2324 rg = r * 2.6,
2325 ));
2326 }
2327
2328 format!(
2329 r#"<figure class="vc-figure" data-vc-figure>
2330 <svg class="vc" viewBox="0 0 {w} {h}" preserveAspectRatio="xMidYMid meet" role="img" aria-label="Finding constellation — {n} findings as a star chart">
2331 <circle class="vc-ring" cx="{cx}" cy="{cy}" r="{rr}"/>
2332 <circle class="vc-center" cx="{cx}" cy="{cy}" r="2.5"/>
2333 <g class="vc-edges">{edges}</g>
2334 <g class="vc-nodes">{nodes}</g>
2335 </svg>
2336 <p class="vc-tooltip" data-vc-tooltip aria-hidden="true"></p>
2337 <p class="vc-legend">
2338 <span><span class="vc-legend__dot" style="background:#3b7a48;"></span>replicated · supported</span>
2339 <span class="vc-sep">·</span>
2340 <span><span class="vc-legend__dot" style="background:#a07a1f;"></span>contested</span>
2341 <span class="vc-sep">·</span>
2342 <span><span class="vc-legend__dot" style="background:#7d7d7d;"></span>gap · inferred</span>
2343 <span class="vc-sep">·</span>
2344 <span><span class="vc-legend__dot" style="background:#9b3232;"></span>retracted</span>
2345 <span class="vc-sep">·</span>
2346 <span><span class="vc-legend__dot" style="background:#3a6a8a;"></span>cross-frontier</span>
2347 <span class="vc-sep">·</span>
2348 <span>radius = confidence · click to focus · esc to clear</span>
2349 </p>
2350 </figure>"#,
2351 w = view_w,
2352 h = view_h,
2353 rr = ring_r,
2354 )
2355}
2356
2357const CONSTELLATION_CSS: &str = r#"
2358:root {
2359 --vc-gold: #c79a3a;
2360 --vc-gold-glow: rgba(199, 154, 58, 0.55);
2361 --vc-winter: #3a6a8a;
2362 --state-ok: #3b7a48;
2363 --state-warn: #a07a1f;
2364 --state-stale: #7d7d7d;
2365 --state-lost: #9b3232;
2366}
2367.vc-stage { display: grid; grid-template-columns: minmax(0, 1fr) 320px; gap: 1.25rem; align-items: start; }
2368@media (max-width: 980px) { .vc-stage { grid-template-columns: 1fr; } }
2369.vc-figure {
2370 margin: 0;
2371 background: #1c1d22;
2372 border: 1px solid var(--rule-2, #d8d4cc);
2373 border-radius: 4px;
2374 overflow: hidden;
2375 position: relative;
2376}
2377.vc { display: block; width: 100%; height: auto; max-height: 460px;
2378 background: radial-gradient(circle at 50% 50%, rgba(199,154,58,0.10) 0%, transparent 38%), #1c1d22; }
2379.vc-ring { fill: none; stroke: rgba(199,154,58,0.28); stroke-width: 0.6; stroke-dasharray: 1 5; }
2380.vc-center { fill: var(--vc-gold); filter: drop-shadow(0 0 6px var(--vc-gold-glow)); }
2381.vc-edges { fill: none; stroke: rgba(199,154,58,0.34); stroke-width: 0.7; pointer-events: none; }
2382.vc-edge { transition: stroke 200ms ease, stroke-width 200ms ease, opacity 200ms ease; }
2383.vc-edge--cross { stroke: rgba(58,106,138,0.62); stroke-width: 0.85; stroke-linecap: round; }
2384.vc-edge--cascade { stroke: var(--vc-gold) !important; stroke-width: 2 !important; opacity: 1 !important;
2385 filter: drop-shadow(0 0 4px var(--vc-gold-glow));
2386 stroke-dasharray: 6 4; animation: vc-flow 1.2s linear infinite; }
2387@keyframes vc-flow { from { stroke-dashoffset: 0; } to { stroke-dashoffset: -20; } }
2388.vc-node { cursor: pointer; outline: none; transition: opacity 200ms ease; }
2389.vc-glow { fill: var(--vc-gold); opacity: 0; transition: opacity 200ms ease; pointer-events: none; }
2390.vc-node:hover .vc-glow, .vc-node:focus .vc-glow { opacity: 0.32; }
2391.vc-dot { transition: r 200ms ease, stroke 200ms ease, stroke-width 200ms ease;
2392 stroke: rgba(255,255,255,0.20); stroke-width: 0.5; }
2393.vc-node:hover .vc-dot, .vc-node:focus .vc-dot { stroke: #fff; stroke-width: 1; }
2394.vc-node--live .vc-dot { filter: drop-shadow(0 0 4px var(--vc-gold-glow)); }
2395.vc-node--live .vc-glow { opacity: 0.18; }
2396.vc--focused .vc-node { opacity: 0.22; }
2397.vc--focused .vc-node--focus { opacity: 1; }
2398.vc--focused .vc-node--related { opacity: 1; }
2399.vc--focused .vc-edge { opacity: 0.16; }
2400.vc--focused .vc-edge--focus { opacity: 1; stroke: var(--vc-gold); stroke-width: 1.4; }
2401.vc--focused .vc-ring { opacity: 0.4; }
2402.vc--focused .vc-center { opacity: 0.5; }
2403.vc-node--focus .vc-glow { opacity: 0.42; }
2404.vc-node--focus .vc-dot { stroke: #fff; stroke-width: 1.4; }
2405.vc-node--cascade-hit .vc-dot { stroke: var(--vc-gold); stroke-width: 2.2;
2406 filter: drop-shadow(0 0 8px var(--vc-gold-glow)); }
2407.vc-node--cascade-hit .vc-glow { opacity: 0.55; }
2408.vc-tooltip { margin: 0; padding: 10px 14px 12px; border-top: 1px solid #303237;
2409 font-size: 13px; line-height: 1.4; color: #e6e2d6; min-height: 1.4em;
2410 background: #232428; opacity: 1; transition: opacity 200ms ease; }
2411.vc-tooltip:empty::before { content: 'Hover a node to read the claim · click to focus.';
2412 color: #8c8a82; font-style: italic; }
2413.vc-tooltip__meta { font-family: ui-monospace, Menlo, monospace; font-size: 11px;
2414 font-weight: 400; letter-spacing: 0.04em; color: #a8a39a; }
2415.vc-legend { margin: 0; padding: 8px 14px 12px; font-family: ui-monospace, Menlo, monospace;
2416 font-size: 10px; letter-spacing: 0.14em; text-transform: uppercase; color: #a8a39a;
2417 display: flex; flex-wrap: wrap; gap: 4px 10px; align-items: center;
2418 border-top: 1px solid #2c2d31; background: transparent; }
2419.vc-legend > span { display: inline-flex; align-items: center; gap: 4px; }
2420.vc-legend__dot { display: inline-block; width: 6px; height: 6px; border-radius: 50%; }
2421.vc-legend .vc-sep { color: #5c5b56; }
2422.vc-empty { padding: 1.5rem; color: var(--ink-2, #6b665d); }
2423
2424.vc-panel { background: var(--bg-2, #f5f2ec); border: 1px solid var(--rule-2, #d8d4cc);
2425 padding: 1rem 1.1rem; font-size: 0.92rem; }
2426.vc-panel[hidden] { display: none; }
2427.vc-panel__head { margin-bottom: 0.6rem; }
2428.vc-panel__eyebrow { font-size: 0.72rem; text-transform: uppercase; letter-spacing: 0.08em;
2429 color: var(--ink-2, #6b665d); }
2430.vc-panel__title { margin: 0.2rem 0; font-size: 1rem; }
2431.vc-panel__id { margin: 0; font-size: 0.78rem; color: var(--ink-2, #6b665d); }
2432.vc-panel__claim { margin: 0.6rem 0 0.8rem; line-height: 1.5; }
2433.vc-panel__meta { display: grid; grid-template-columns: 1fr 1fr; gap: 0.3rem 0.8rem;
2434 margin: 0 0 1rem 0; font-size: 0.84rem; }
2435.vc-panel__meta div { display: flex; flex-direction: column; }
2436.vc-panel__meta dt { font-size: 0.7rem; text-transform: uppercase; letter-spacing: 0.06em;
2437 color: var(--ink-2, #6b665d); }
2438.vc-panel__meta dd { margin: 0.05rem 0 0 0; font-family: ui-monospace, Menlo, monospace;
2439 font-size: 0.86rem; }
2440.vc-panel__cascade { display: flex; flex-direction: column; gap: 0.4rem;
2441 border-top: 1px solid var(--rule-2, #d8d4cc); padding-top: 0.8rem; margin-top: 0.4rem; }
2442.vc-panel__cascade label { font-size: 0.78rem; color: var(--ink-2, #6b665d); }
2443.vc-panel__cascade input[type=range] { width: 100%; }
2444.vc-panel__cascade output { font-family: ui-monospace, Menlo, monospace; font-size: 0.92rem;
2445 font-weight: 600; }
2446.vc-panel__cascade button { font-family: inherit; font-size: 0.84rem; padding: 0.4rem 0.7rem;
2447 border: 1px solid #1a1a1a; background: #1a1a1a; color: #fff; cursor: pointer; border-radius: 2px; }
2448.vc-panel__cascade button:disabled { opacity: 0.5; cursor: wait; }
2449.vc-panel__note { margin: 0.4rem 0 0 0; font-size: 0.82rem; color: var(--ink-2, #6b665d);
2450 min-height: 1.1em; line-height: 1.4; }
2451.vc-panel__note.is-success { color: #2f5d3a; }
2452.vc-panel__note.is-error { color: #872c2c; }
2453.vc-panel__open { margin: 0.8rem 0 0 0; font-size: 0.82rem; }
2454.vc-panel__open a { color: #1a1a1a; }
2455"#;
2456
2457const CONSTELLATION_JS: &str = r#"
2458(function(){
2459 var fig = document.querySelector('[data-vc-figure]');
2460 var panel = document.querySelector('[data-vc-panel]');
2461 if (!fig || !panel) return;
2462 var nodes = fig.querySelectorAll('.vc-node');
2463 var edges = fig.querySelectorAll('.vc-edge');
2464 var tip = fig.querySelector('[data-vc-tooltip]');
2465 var focused = null;
2466
2467 var pTitle = panel.querySelector('[data-vc-panel-title]');
2468 var pId = panel.querySelector('[data-vc-panel-id]');
2469 var pClaim = panel.querySelector('[data-vc-panel-claim]');
2470 var pConf = panel.querySelector('[data-vc-panel-conf]');
2471 var pState = panel.querySelector('[data-vc-panel-state]');
2472 var pIn = panel.querySelector('[data-vc-panel-deps-in]');
2473 var pOut = panel.querySelector('[data-vc-panel-deps-out]');
2474 var pOpen = panel.querySelector('[data-vc-panel-open]');
2475 var form = panel.querySelector('[data-vc-cascade-form]');
2476 var slider = panel.querySelector('[data-vc-cascade-slider]');
2477 var readout = panel.querySelector('[data-vc-cascade-readout]');
2478 var status = panel.querySelector('[data-vc-cascade-status]');
2479 var button = form ? form.querySelector('button') : null;
2480
2481 function clearTip(){ tip.innerHTML = ''; }
2482 function showTipFromNode(n){
2483 var claim = n.getAttribute('data-claim') || '';
2484 var nOut = parseInt(n.getAttribute('data-deps-out') || '0', 10);
2485 var nIn = parseInt(n.getAttribute('data-deps-in') || '0', 10);
2486 var meta = nOut + ' dep' + (nOut === 1 ? '' : 's') + ' · ' + nIn + ' dependent' + (nIn === 1 ? '' : 's');
2487 tip.innerHTML = claim + ' <span class="vc-tooltip__meta">· ' + meta + '</span>';
2488 }
2489
2490 function relatedSet(vf){
2491 var related = {};
2492 edges.forEach(function(e){
2493 var from = e.getAttribute('data-from');
2494 var to = e.getAttribute('data-to');
2495 if (from === vf) { related[to] = true; e.classList.add('vc-edge--focus'); }
2496 else if (to === vf) { related[from] = true; e.classList.add('vc-edge--focus'); }
2497 else { e.classList.remove('vc-edge--focus'); }
2498 });
2499 return related;
2500 }
2501
2502 function fillPanel(node){
2503 var vf = node.getAttribute('data-vf');
2504 var claim = node.getAttribute('data-claim') || '';
2505 var conf = parseFloat(node.getAttribute('data-conf') || '0');
2506 var st = node.getAttribute('data-state') || '—';
2507 var nOut = parseInt(node.getAttribute('data-deps-out') || '0', 10);
2508 var nIn = parseInt(node.getAttribute('data-deps-in') || '0', 10);
2509 pTitle.textContent = vf;
2510 pId.textContent = vf;
2511 pClaim.textContent = claim;
2512 pConf.textContent = conf.toFixed(3);
2513 pState.textContent = st;
2514 pIn.textContent = String(nIn);
2515 pOut.textContent = String(nOut);
2516 pOpen.setAttribute('href', '/findings/' + vf);
2517 panel.removeAttribute('hidden');
2518 if (status) { status.textContent = ''; status.classList.remove('is-success','is-error'); }
2519 if (button) { button.disabled = false; }
2520 }
2521
2522 function applyFocus(node){
2523 var vf = node.getAttribute('data-vf');
2524 focused = vf;
2525 fig.classList.add('vc--focused');
2526 var related = relatedSet(vf);
2527 nodes.forEach(function(n){
2528 var nv = n.getAttribute('data-vf');
2529 n.classList.remove('vc-node--focus','vc-node--related','vc-node--cascade-hit');
2530 if (nv === vf) n.classList.add('vc-node--focus');
2531 else if (related[nv]) n.classList.add('vc-node--related');
2532 });
2533 edges.forEach(function(e){ e.classList.remove('vc-edge--cascade'); });
2534 showTipFromNode(node);
2535 fillPanel(node);
2536 }
2537
2538 function clearFocus(){
2539 focused = null;
2540 fig.classList.remove('vc--focused');
2541 nodes.forEach(function(n){ n.classList.remove('vc-node--focus','vc-node--related'); });
2542 edges.forEach(function(e){ e.classList.remove('vc-edge--focus'); });
2543 clearTip();
2544 }
2545
2546 nodes.forEach(function(n){
2547 n.addEventListener('mouseenter', function(){ if (!focused) showTipFromNode(n); });
2548 n.addEventListener('mouseleave', function(){ if (!focused) clearTip(); });
2549 n.addEventListener('click', function(e){
2550 var vf = n.getAttribute('data-vf');
2551 if (focused === vf) { return; } // second click → navigate
2552 e.preventDefault();
2553 applyFocus(n);
2554 });
2555 });
2556
2557 document.addEventListener('keydown', function(e){
2558 if (e.key === 'Escape' && focused) { clearFocus(); }
2559 });
2560
2561 if (slider && readout) {
2562 var sync = function(){ readout.textContent = (slider.value/100).toFixed(2); };
2563 slider.addEventListener('input', sync);
2564 sync();
2565 }
2566
2567 if (form) {
2568 form.addEventListener('submit', function(e){
2569 e.preventDefault();
2570 if (!focused) { return; }
2571 var newScore = (slider ? slider.value : 40) / 100;
2572 var fd = new URLSearchParams();
2573 fd.append('new_score', String(newScore));
2574 fd.append('reason', 'Workbench cascade fire from constellation');
2575 fd.append('reviewer', 'reviewer:workbench');
2576 if (button) button.disabled = true;
2577 if (status) { status.textContent = 'firing cascade…'; status.classList.remove('is-success','is-error'); }
2578 fetch('/api/propagate/' + encodeURIComponent(focused), {
2579 method: 'POST',
2580 headers: {'Content-Type': 'application/x-www-form-urlencoded'},
2581 body: fd.toString()
2582 }).then(function(r){ return r.json(); }).then(function(j){
2583 if (button) button.disabled = false;
2584 if (!j.ok) {
2585 if (status) { status.textContent = j.message || 'cascade failed'; status.classList.add('is-error'); }
2586 return;
2587 }
2588 var hits = (j.affected || []);
2589 if (status) {
2590 status.classList.add('is-success');
2591 status.textContent = 'cascade fired · confidence ' + j.new_confidence.toFixed(2) +
2592 ' · ' + j.cascade_events + ' downstream flagged';
2593 }
2594 // Animate gold edges from focused → each affected.
2595 var fromVf = focused;
2596 edges.forEach(function(e){
2597 var from = e.getAttribute('data-from');
2598 var to = e.getAttribute('data-to');
2599 if ((from === fromVf && hits.indexOf(to) >= 0) ||
2600 (to === fromVf && hits.indexOf(from) >= 0)) {
2601 e.classList.add('vc-edge--cascade');
2602 }
2603 });
2604 // Pulse hit nodes.
2605 nodes.forEach(function(n){
2606 if (hits.indexOf(n.getAttribute('data-vf')) >= 0) {
2607 n.classList.add('vc-node--cascade-hit');
2608 }
2609 });
2610 // Update local conf readout for source.
2611 if (pConf) pConf.textContent = j.new_confidence.toFixed(3);
2612 }).catch(function(err){
2613 if (button) button.disabled = false;
2614 if (status) { status.textContent = String(err); status.classList.add('is-error'); }
2615 });
2616 });
2617 }
2618})();
2619"#;
2620
2621async fn page_replay(AxumPath(vf_id): AxumPath<String>, State(state): State<AppState>) -> Response {
2624 let payload = match state::history_as_of(&state.repo_path, &vf_id, None) {
2625 Ok(v) => v,
2626 Err(e) => return error_page("replay", "history failed", &e),
2627 };
2628 let project = match repo::load_from_path(&state.repo_path) {
2629 Ok(p) => p,
2630 Err(e) => return error_page("replay", "load failed", &e),
2631 };
2632
2633 let assertion = payload
2634 .pointer("/finding/assertion")
2635 .and_then(|v| v.as_str())
2636 .unwrap_or("(no assertion)")
2637 .to_string();
2638 let current_conf = payload
2639 .pointer("/finding/confidence")
2640 .and_then(|v| v.as_f64())
2641 .unwrap_or(0.0);
2642
2643 #[derive(Clone)]
2647 struct ReplayPoint {
2648 ts: String,
2649 kind: String,
2650 previous: Option<f64>,
2651 new: Option<f64>,
2652 reason: String,
2653 }
2654 let empty = Vec::new();
2655 let events = payload
2656 .pointer("/events")
2657 .and_then(|v| v.as_array())
2658 .unwrap_or(&empty);
2659 let mut points: Vec<ReplayPoint> = events
2660 .iter()
2661 .map(|e| ReplayPoint {
2662 ts: e
2663 .get("timestamp")
2664 .and_then(|v| v.as_str())
2665 .unwrap_or("")
2666 .to_string(),
2667 kind: e
2668 .get("kind")
2669 .and_then(|v| v.as_str())
2670 .unwrap_or("")
2671 .to_string(),
2672 previous: e
2673 .pointer("/payload/previous_score")
2674 .and_then(|v| v.as_f64()),
2675 new: e
2676 .pointer("/payload/new_score")
2677 .and_then(|v| v.as_f64())
2678 .or_else(|| e.pointer("/payload/confidence").and_then(|v| v.as_f64())),
2679 reason: e
2680 .get("reason")
2681 .and_then(|v| v.as_str())
2682 .unwrap_or("")
2683 .to_string(),
2684 })
2685 .collect();
2686 points.sort_by(|a, b| a.ts.cmp(&b.ts));
2687
2688 let mut series: Vec<(String, f64, String)> = Vec::new();
2690 let mut last = if let Some(p) = points.first() {
2691 p.previous.unwrap_or(current_conf)
2692 } else {
2693 current_conf
2694 };
2695 for p in &points {
2696 let score = p.new.unwrap_or_else(|| p.previous.unwrap_or(last));
2697 series.push((p.ts.clone(), score, p.kind.clone()));
2698 last = score;
2699 }
2700 if series.is_empty() {
2701 series.push((String::new(), current_conf, "current".to_string()));
2702 }
2703
2704 let view_w = 720i32;
2706 let view_h = 140i32;
2707 let pad_l = 40.0;
2708 let pad_r = 20.0;
2709 let pad_t = 16.0;
2710 let pad_b = 28.0;
2711 let plot_w = view_w as f64 - pad_l - pad_r;
2712 let plot_h = view_h as f64 - pad_t - pad_b;
2713 let n = series.len() as f64;
2714 let mut path = String::new();
2715 let mut points_svg = String::new();
2716 for (i, (_, score, kind)) in series.iter().enumerate() {
2717 let x = pad_l + (i as f64 / (n - 1.0).max(1.0)) * plot_w;
2718 let y = pad_t + (1.0 - score.clamp(0.0, 1.0)) * plot_h;
2719 if i == 0 {
2720 path.push_str(&format!("M {x:.1} {y:.1}"));
2721 } else {
2722 path.push_str(&format!(" L {x:.1} {y:.1}"));
2723 }
2724 let dot_class = match kind.as_str() {
2725 "finding.asserted" => "rp-dot rp-dot--genesis",
2726 "finding.confidence_revised" => "rp-dot rp-dot--revise",
2727 "finding.retracted" | "finding.flagged" => "rp-dot rp-dot--retract",
2728 "finding.dependency_invalidated" => "rp-dot rp-dot--cascade",
2729 _ => "rp-dot",
2730 };
2731 points_svg.push_str(&format!(
2732 r#"<circle class="{dot_class}" cx="{x:.1}" cy="{y:.1}" r="3.5"><title>{score:.2} · {kind}</title></circle>"#,
2733 ));
2734 }
2735 let threshold_y = pad_t + (1.0 - 0.5) * plot_h;
2736 let svg = format!(
2737 r#"<svg class="rp-svg" viewBox="0 0 {view_w} {view_h}" preserveAspectRatio="xMidYMid meet" role="img" aria-label="Confidence trajectory over time">
2738 <line class="rp-axis" x1="{pad_l}" y1="{ax_y}" x2="{x_end}" y2="{ax_y}"/>
2739 <line class="rp-axis" x1="{pad_l}" y1="{pad_t}" x2="{pad_l}" y2="{ax_y}"/>
2740 <line class="rp-threshold" x1="{pad_l}" y1="{threshold_y:.1}" x2="{x_end}" y2="{threshold_y:.1}"><title>0.5 cascade threshold</title></line>
2741 <text class="rp-label" x="6" y="{pad_t}" dy="0.32em">1.0</text>
2742 <text class="rp-label" x="6" y="{ax_y}" dy="0.32em">0.0</text>
2743 <text class="rp-label" x="6" y="{threshold_y:.1}" dy="0.32em">0.5</text>
2744 <path class="rp-line" d="{path}"/>
2745 {points_svg}
2746</svg>"#,
2747 ax_y = pad_t + plot_h,
2748 x_end = pad_l + plot_w,
2749 );
2750
2751 let mut rows = String::new();
2753 for p in points.iter().rev() {
2754 let kind_chip = match p.kind.as_str() {
2755 "finding.asserted" => ("ok", "asserted"),
2756 "finding.confidence_revised" => ("warn", "revised"),
2757 "finding.retracted" => ("lost", "retracted"),
2758 "finding.dependency_invalidated" => ("warn", "cascade"),
2759 "finding.reviewed" => ("ok", "reviewed"),
2760 "finding.flagged" => ("warn", "flagged"),
2761 _ => ("warn", p.kind.as_str()),
2762 };
2763 let from = p
2764 .previous
2765 .map(|v| format!("{v:.2}"))
2766 .unwrap_or("—".to_string());
2767 let to = p.new.map(|v| format!("{v:.2}")).unwrap_or("—".to_string());
2768 rows.push_str(&format!(
2769 r#"<tr>
2770 <td><code>{ts}</code></td>
2771 <td><span class="wb-chip wb-chip--{c}">{label}</span></td>
2772 <td><code>{from}</code> → <code>{to}</code></td>
2773 <td>{reason}</td>
2774</tr>"#,
2775 ts = escape_html(&p.ts),
2776 c = kind_chip.0,
2777 label = escape_html(kind_chip.1),
2778 reason = escape_html(&p.reason),
2779 ));
2780 }
2781 if rows.is_empty() {
2782 rows =
2783 r#"<tr><td colspan="4">No events recorded for this finding yet.</td></tr>"#.to_string();
2784 }
2785
2786 let n_revisions = points
2787 .iter()
2788 .filter(|p| p.kind == "finding.confidence_revised")
2789 .count();
2790 let n_cascades = points
2791 .iter()
2792 .filter(|p| p.kind == "finding.dependency_invalidated")
2793 .count();
2794 let stats = format!(
2795 r#"<div class="wb-stats">
2796 <div><div class="wb-stat__num">{}</div><div class="wb-stat__label">events</div></div>
2797 <div><div class="wb-stat__num">{n_revisions}</div><div class="wb-stat__label">revisions</div></div>
2798 <div><div class="wb-stat__num">{n_cascades}</div><div class="wb-stat__label">cascade hits</div></div>
2799 <div><div class="wb-stat__num">{current_conf:.2}</div><div class="wb-stat__label">current</div></div>
2800</div>"#,
2801 points.len(),
2802 );
2803
2804 let _ = project; let body = format!(
2807 r#"{stats}
2808<p class="wb-eyebrow" style="margin-top:0.4rem;">Confidence trajectory and event timeline for <code>{vf}</code>. The dashed line marks the 0.5 cascade threshold — drops below it propagate through dependents.</p>
2809<p style="font-size:0.95rem;line-height:1.5;margin:0.4rem 0 1rem;">{claim}</p>
2810<div class="rp-figure">{svg}</div>
2811<p class="rp-legend">
2812 <span><span class="rp-legend__dot" style="background:#3b7a48;"></span>asserted</span>
2813 <span><span class="rp-legend__dot" style="background:#a07a1f;"></span>revised</span>
2814 <span><span class="rp-legend__dot" style="background:#9b3232;"></span>retracted</span>
2815 <span><span class="rp-legend__dot" style="background:#c79a3a;"></span>cascade hit</span>
2816</p>
2817<table class="wb-table">
2818 <thead><tr><th>at</th><th>kind</th><th>score</th><th>reason</th></tr></thead>
2819 <tbody>{rows}</tbody>
2820</table>
2821<style>{css}</style>
2822<p style="margin-top:1rem;font-size:0.86rem;"><a href="/findings/{vf}">← back to finding detail</a> · <a href="/constellation">← constellation</a></p>"#,
2823 vf = escape_html(&vf_id),
2824 claim = escape_html(&assertion),
2825 css = REPLAY_CSS,
2826 );
2827
2828 Html(shell(
2829 "constellation",
2830 "Time-travel replay",
2831 "Workbench",
2832 "Time-travel replay",
2833 &body,
2834 ))
2835 .into_response()
2836}
2837
2838const REPLAY_CSS: &str = r#"
2839.rp-figure { background: #1c1d22; border: 1px solid var(--rule-2, #d8d4cc); border-radius: 4px; padding: 0; margin: 1rem 0 0.5rem; overflow: hidden; }
2840.rp-svg { display: block; width: 100%; height: auto; max-height: 200px;
2841 background: radial-gradient(circle at 50% 50%, rgba(199,154,58,0.10) 0%, transparent 38%), #1c1d22; }
2842.rp-axis { stroke: rgba(255,255,255,0.18); stroke-width: 0.7; }
2843.rp-threshold { stroke: rgba(199,154,58,0.55); stroke-width: 0.6; stroke-dasharray: 3 3; }
2844.rp-label { font-family: ui-monospace, Menlo, monospace; font-size: 9px; fill: #a8a39a; }
2845.rp-line { fill: none; stroke: #c79a3a; stroke-width: 1.6; filter: drop-shadow(0 0 4px rgba(199,154,58,0.45)); }
2846.rp-dot { stroke: #1c1d22; stroke-width: 1; fill: #c79a3a; }
2847.rp-dot--genesis { fill: #3b7a48; }
2848.rp-dot--revise { fill: #a07a1f; }
2849.rp-dot--retract { fill: #9b3232; }
2850.rp-dot--cascade { fill: #c79a3a; }
2851.rp-legend { margin: 0.3rem 0 1rem; font-family: ui-monospace, Menlo, monospace; font-size: 10px;
2852 letter-spacing: 0.14em; text-transform: uppercase; color: var(--ink-2, #6b665d);
2853 display: flex; flex-wrap: wrap; gap: 4px 12px; }
2854.rp-legend > span { display: inline-flex; align-items: center; gap: 4px; }
2855.rp-legend__dot { display: inline-block; width: 8px; height: 8px; border-radius: 50%; }
2856"#;
2857
2858#[derive(Deserialize)]
2859struct ProposalDecisionForm {
2860 #[serde(default)]
2861 reviewer: Option<String>,
2862 #[serde(default)]
2863 reason: Option<String>,
2864}
2865
2866fn proposal_decision(form: ProposalDecisionForm) -> (String, String) {
2867 let reviewer = form
2868 .reviewer
2869 .filter(|value| !value.trim().is_empty())
2870 .unwrap_or_else(|| "reviewer:workbench".to_string());
2871 let reason = form
2872 .reason
2873 .filter(|value| !value.trim().is_empty())
2874 .unwrap_or_else(|| "Workbench review decision.".to_string());
2875 (reviewer, reason)
2876}
2877
2878async fn post_proposal_accept(
2879 AxumPath(vpr_id): AxumPath<String>,
2880 State(state): State<AppState>,
2881 Form(form): Form<ProposalDecisionForm>,
2882) -> Response {
2883 let (reviewer, reason) = proposal_decision(form);
2884 match proposals::accept_at_path(&state.repo_path, &vpr_id, &reviewer, &reason) {
2885 Ok(_) => Redirect::to("/proposals").into_response(),
2886 Err(e) => error_page("proposals", "Could not accept proposal", &e),
2887 }
2888}
2889
2890async fn post_proposal_reject(
2891 AxumPath(vpr_id): AxumPath<String>,
2892 State(state): State<AppState>,
2893 Form(form): Form<ProposalDecisionForm>,
2894) -> Response {
2895 let (reviewer, reason) = proposal_decision(form);
2896 match proposals::reject_at_path(&state.repo_path, &vpr_id, &reviewer, &reason) {
2897 Ok(()) => Redirect::to("/proposals").into_response(),
2898 Err(e) => error_page("proposals", "Could not reject proposal", &e),
2899 }
2900}
2901
2902async fn post_proposal_revision(
2903 AxumPath(vpr_id): AxumPath<String>,
2904 State(state): State<AppState>,
2905 Form(form): Form<ProposalDecisionForm>,
2906) -> Response {
2907 let (reviewer, reason) = proposal_decision(form);
2908 match proposals::request_revision_at_path(&state.repo_path, &vpr_id, &reviewer, &reason) {
2909 Ok(()) => Redirect::to("/proposals").into_response(),
2910 Err(e) => error_page("proposals", "Could not request revision", &e),
2911 }
2912}
2913
2914async fn post_bridge_confirm(
2915 AxumPath(vbr_id): AxumPath<String>,
2916 State(state): State<AppState>,
2917) -> Response {
2918 set_bridge_status(&state.repo_path, &vbr_id, BridgeStatus::Confirmed);
2919 Redirect::to("/bridges").into_response()
2920}
2921
2922async fn post_bridge_refute(
2923 AxumPath(vbr_id): AxumPath<String>,
2924 State(state): State<AppState>,
2925) -> Response {
2926 set_bridge_status(&state.repo_path, &vbr_id, BridgeStatus::Refuted);
2927 Redirect::to("/bridges").into_response()
2928}
2929
2930fn bridges_dir(repo_path: &Path) -> PathBuf {
2933 repo_path.join(".vela/bridges")
2934}
2935
2936fn list_bridges(repo_path: &Path) -> Vec<Bridge> {
2937 let dir = bridges_dir(repo_path);
2938 if !dir.is_dir() {
2939 return Vec::new();
2940 }
2941 let mut out = Vec::new();
2942 if let Ok(entries) = std::fs::read_dir(&dir) {
2943 for e in entries.flatten() {
2944 let p = e.path();
2945 if p.extension().and_then(|s| s.to_str()) != Some("json") {
2946 continue;
2947 }
2948 if let Ok(data) = std::fs::read_to_string(&p)
2949 && let Ok(b) = serde_json::from_str::<Bridge>(&data)
2950 {
2951 out.push(b);
2952 }
2953 }
2954 }
2955 out.sort_by(|a, b| {
2956 b.finding_refs
2957 .len()
2958 .cmp(&a.finding_refs.len())
2959 .then(a.entity_name.cmp(&b.entity_name))
2960 });
2961 out
2962}
2963
2964fn set_bridge_status(repo_path: &Path, vbr_id: &str, status: BridgeStatus) {
2965 let p = bridges_dir(repo_path).join(format!("{vbr_id}.json"));
2966 let Ok(data) = std::fs::read_to_string(&p) else {
2967 return;
2968 };
2969 let Ok(mut b) = serde_json::from_str::<Bridge>(&data) else {
2970 return;
2971 };
2972 b.status = status;
2973 if let Ok(out) = serde_json::to_string_pretty(&b) {
2974 let _ = std::fs::write(&p, format!("{out}\n"));
2975 }
2976}
2977
2978async fn static_tokens_css() -> Response {
2981 css_response(TOKENS_CSS)
2982}
2983async fn static_workbench_css() -> Response {
2984 css_response(WORKBENCH_CSS)
2985}
2986async fn static_favicon_svg() -> Response {
2987 svg_response(FAVICON_SVG)
2988}
2989async fn healthz() -> Response {
2990 (StatusCode::OK, "ok").into_response()
2991}
2992
2993fn css_response(body: &'static str) -> Response {
2994 (
2995 StatusCode::OK,
2996 [
2997 (axum::http::header::CONTENT_TYPE, "text/css; charset=utf-8"),
2998 (axum::http::header::CACHE_CONTROL, "public, max-age=300"),
2999 ],
3000 body,
3001 )
3002 .into_response()
3003}
3004
3005fn svg_response(body: &'static str) -> Response {
3006 (
3007 StatusCode::OK,
3008 [
3009 (axum::http::header::CONTENT_TYPE, "image/svg+xml"),
3010 (axum::http::header::CACHE_CONTROL, "public, max-age=300"),
3011 ],
3012 body,
3013 )
3014 .into_response()
3015}
3016
3017fn error_page(active: &str, title: &str, message: &str) -> Response {
3018 let body = format!(
3019 r#"<div class="wb-card"><h3>{title}</h3><p>{msg}</p></div>"#,
3020 title = escape_html(title),
3021 msg = escape_html(message)
3022 );
3023 let html = shell(active, title, "Workbench", title, &body);
3024 (StatusCode::INTERNAL_SERVER_ERROR, Html(html)).into_response()
3025}
3026
3027fn default_reviewer() -> String {
3030 std::env::var("VELA_REVIEWER_ID").unwrap_or_else(|_| "reviewer:will-blair".to_string())
3031}
3032
3033fn actor_datalist(project: &Project) -> String {
3038 if project.actors.is_empty() {
3039 return String::new();
3040 }
3041 let mut html = String::from(r#"<datalist id="vela-actors">"#);
3042 for actor in &project.actors {
3043 html.push_str(&format!(r#"<option value="{}">"#, escape_html(&actor.id)));
3044 }
3045 html.push_str("</datalist>");
3046 html
3047}
3048
3049#[derive(Debug, Deserialize)]
3050struct LocatorRepairForm {
3051 atom_id: String,
3052 locator: String,
3053 reviewer: String,
3054 reason: String,
3055}
3056
3057#[derive(Debug, Deserialize)]
3058struct SpanRepairForm {
3059 finding_id: String,
3060 section: String,
3061 text: String,
3062 reviewer: String,
3063 reason: String,
3064}
3065
3066#[derive(Debug, Deserialize)]
3067struct EntityResolveForm {
3068 finding_id: String,
3069 entity_name: String,
3070 source: String,
3071 id: String,
3072 confidence: f64,
3073 matched_name: Option<String>,
3074 resolution_method: String,
3075 reviewer: String,
3076 reason: String,
3077}
3078
3079#[derive(Debug, Deserialize)]
3080struct PromoteForm {
3081 finding_id: String,
3082 status: String,
3083 reviewer: String,
3084 reason: String,
3085}
3086
3087#[derive(Debug, Deserialize)]
3088struct ConflictResolveForm {
3089 conflict_event_id: String,
3090 resolution_note: String,
3091 reviewer: String,
3092 winning_proposal_id: Option<String>,
3093}
3094
3095#[derive(Debug, Deserialize)]
3096struct ReplicationAddForm {
3097 finding_id: String,
3098 outcome: String,
3099 attempted_by: String,
3100 conditions_text: String,
3101 source_title: String,
3102 #[serde(default)]
3103 doi: String,
3104 #[serde(default)]
3105 pmid: String,
3106 #[serde(default)]
3107 note: String,
3108}
3109
3110#[derive(Debug, Deserialize)]
3111struct PredictionAddForm {
3112 finding_id: String,
3113 claim_text: String,
3114 resolves_by: String,
3115 resolution_criterion: String,
3116 expected_outcome: String,
3117 made_by: String,
3118 confidence: f64,
3119 conditions_text: String,
3120}
3121
3122#[derive(Debug, Deserialize, Default)]
3127struct InboxFilter {
3128 #[serde(default)]
3129 source: String,
3130}
3131
3132async fn page_review_inbox(
3133 State(state): State<AppState>,
3134 Query(filter): Query<InboxFilter>,
3135) -> Response {
3136 let project = match repo::load_from_path(&state.repo_path) {
3137 Ok(p) => p,
3138 Err(e) => return error_page("review", "Could not load frontier", &e),
3139 };
3140
3141 let locator_gaps: Vec<&crate::sources::EvidenceAtom> = project
3142 .evidence_atoms
3143 .iter()
3144 .filter(|a| a.locator.is_none())
3145 .take(20)
3146 .collect();
3147 let span_gaps: Vec<&FindingBundle> = project
3148 .findings
3149 .iter()
3150 .filter(|f| f.evidence.evidence_spans.is_empty())
3151 .take(20)
3152 .collect();
3153 let entity_gaps: Vec<&FindingBundle> = project
3154 .findings
3155 .iter()
3156 .filter(|f| f.assertion.entities.iter().any(|e| e.needs_review))
3157 .take(20)
3158 .collect();
3159 let link_gaps: Vec<&FindingBundle> = project
3160 .findings
3161 .iter()
3162 .filter(|f| f.links.is_empty())
3163 .take(20)
3164 .collect();
3165
3166 let source_filter = filter.source.trim().to_ascii_lowercase();
3175 let matches_source_filter = |f: &FindingBundle| -> bool {
3176 if source_filter.is_empty() {
3177 return true;
3178 }
3179 let doi_match = f
3180 .provenance
3181 .doi
3182 .as_deref()
3183 .map(|d| d.to_ascii_lowercase())
3184 .map(|d| {
3185 d.starts_with(&source_filter) || format!("doi:{d}").starts_with(&source_filter)
3186 })
3187 .unwrap_or(false);
3188 let pmid_match = f
3189 .provenance
3190 .pmid
3191 .as_deref()
3192 .map(|p| p.to_ascii_lowercase())
3193 .map(|p| {
3194 p.starts_with(&source_filter) || format!("pmid:{p}").starts_with(&source_filter)
3195 })
3196 .unwrap_or(false);
3197 doi_match || pmid_match
3198 };
3199 let promote_pending: Vec<&FindingBundle> = project
3200 .findings
3201 .iter()
3202 .filter(|f| {
3203 matches!(
3204 f.flags.review_state,
3205 None | Some(crate::bundle::ReviewState::NeedsRevision)
3206 )
3207 })
3208 .filter(|f| matches_source_filter(f))
3209 .take(20)
3210 .collect();
3211 let total_promote = project
3212 .findings
3213 .iter()
3214 .filter(|f| {
3215 matches!(
3216 f.flags.review_state,
3217 None | Some(crate::bundle::ReviewState::NeedsRevision)
3218 )
3219 })
3220 .count();
3221
3222 let federation_conflicts: Vec<&crate::events::StateEvent> = project
3227 .events
3228 .iter()
3229 .filter(|e| e.kind == "frontier.conflict_detected")
3230 .rev()
3231 .take(20)
3232 .collect();
3233 let total_conflicts = project
3234 .events
3235 .iter()
3236 .filter(|e| e.kind == "frontier.conflict_detected")
3237 .count();
3238
3239 let total_locator = project
3240 .evidence_atoms
3241 .iter()
3242 .filter(|a| a.locator.is_none())
3243 .count();
3244 let total_span = project
3245 .findings
3246 .iter()
3247 .filter(|f| f.evidence.evidence_spans.is_empty())
3248 .count();
3249 let total_entity = project
3250 .findings
3251 .iter()
3252 .filter(|f| f.assertion.entities.iter().any(|e| e.needs_review))
3253 .count();
3254 let total_link = project
3255 .findings
3256 .iter()
3257 .filter(|f| f.links.is_empty())
3258 .count();
3259
3260 let mut body = String::new();
3261 body.push_str(&format!(
3264 r#"<form method="get" action="/review/inbox" style="margin:0 0 0.6rem 0;display:flex;gap:0.5rem;align-items:center;">
3265<label for="wb-source-filter" style="color:var(--ink-3);font-size:0.86rem;">Filter pending review by source:</label>
3266<input id="wb-source-filter" name="source" value="{source_val}" placeholder="doi:10.1056/ or pmid:36811" style="flex:0 0 18rem;">
3267<button type="submit">Apply filter</button>
3268{clear_link}
3269</form>"#,
3270 source_val = escape_html(&filter.source),
3271 clear_link = if filter.source.trim().is_empty() {
3272 String::new()
3273 } else {
3274 r#"<a href="/review/inbox" style="color:var(--ink-3);">Clear</a>"#.to_string()
3275 },
3276 ));
3277 body.push_str(r#"<div class="wb-stats">"#);
3278 for (n, label) in [
3279 (total_locator, "missing locator"),
3280 (total_span, "missing span"),
3281 (total_entity, "needs review"),
3282 (total_link, "no links"),
3283 (total_promote, "pending review"),
3284 (total_conflicts, "federation conflicts"),
3285 ] {
3286 body.push_str(&format!(
3287 r#"<div><div class="wb-stat__num">{n}</div><div class="wb-stat__label">{label}</div></div>"#
3288 ));
3289 }
3290 body.push_str("</div>");
3291
3292 let cutoff_seven_days = chrono::Utc::now() - chrono::Duration::days(7);
3297 let events_last_7d: Vec<&crate::events::StateEvent> = project
3298 .events
3299 .iter()
3300 .filter(|e| {
3301 chrono::DateTime::parse_from_rfc3339(&e.timestamp)
3302 .map(|dt| dt.with_timezone(&chrono::Utc) >= cutoff_seven_days)
3303 .unwrap_or(false)
3304 })
3305 .collect();
3306 let total_events_7d = events_last_7d.len();
3307 let mut kind_counts: std::collections::BTreeMap<&str, usize> =
3308 std::collections::BTreeMap::new();
3309 for e in &events_last_7d {
3310 *kind_counts.entry(e.kind.as_str()).or_insert(0) += 1;
3311 }
3312 let mut top_kinds: Vec<(&str, usize)> = kind_counts.into_iter().collect();
3313 top_kinds.sort_by(|a, b| b.1.cmp(&a.1).then(a.0.cmp(b.0)));
3314 let top_kinds: Vec<(&str, usize)> = top_kinds.into_iter().take(5).collect();
3315
3316 let event_by_id: std::collections::HashMap<&str, &crate::events::StateEvent> =
3320 project.events.iter().map(|e| (e.id.as_str(), e)).collect();
3321 let mut latencies_sec: Vec<i64> = Vec::new();
3322 let mut applied_count: usize = 0;
3323 let mut pending_count: usize = 0;
3324 for p in &project.proposals {
3325 match p.status.as_str() {
3326 "applied" => {
3327 applied_count += 1;
3328 let queue_start = p.drafted_at.as_deref().unwrap_or(p.created_at.as_str());
3335 if let Some(eid) = p.applied_event_id.as_deref()
3336 && let Some(ev) = event_by_id.get(eid)
3337 && let (Ok(c), Ok(a)) = (
3338 chrono::DateTime::parse_from_rfc3339(queue_start),
3339 chrono::DateTime::parse_from_rfc3339(&ev.timestamp),
3340 )
3341 {
3342 let secs = (a.timestamp() - c.timestamp()).max(0);
3343 latencies_sec.push(secs);
3344 }
3345 }
3346 "pending_review" => pending_count += 1,
3347 _ => {}
3348 }
3349 }
3350 latencies_sec.sort_unstable();
3351 let median_latency_sec = if latencies_sec.is_empty() {
3352 None
3353 } else {
3354 Some(latencies_sec[latencies_sec.len() / 2])
3355 };
3356 let median_latency_label = match median_latency_sec {
3357 None => "n/a".to_string(),
3358 Some(s) if s < 60 => format!("{s}s"),
3359 Some(s) if s < 3600 => format!("{}m", s / 60),
3360 Some(s) if s < 86400 => format!("{}h", s / 3600),
3361 Some(s) => format!("{}d", s / 86400),
3362 };
3363 let total_proposals = project.proposals.len();
3364 let applied_pct = if total_proposals == 0 {
3365 0
3366 } else {
3367 (applied_count * 100) / total_proposals
3368 };
3369
3370 body.push_str(r#"<div class="wb-card"><h3>Throughput, last 7 days</h3>"#);
3371 body.push_str(&format!(
3372 r#"<p style="color:var(--ink-3);font-size:0.86rem;">{total_events_7d} canonical events in the last 7 days. {applied_count} of {total_proposals} proposals applied ({applied_pct}%); {pending_count} still pending. Median time from proposal to applied event: <code>{median_latency_label}</code>.</p>"#
3373 ));
3374 if !top_kinds.is_empty() {
3375 body.push_str(r#"<table class="wb-table"><thead><tr><th>kind</th><th>count (7d)</th></tr></thead><tbody>"#);
3376 for (k, n) in &top_kinds {
3377 body.push_str(&format!(
3378 r#"<tr><td><code>{kind}</code></td><td>{n}</td></tr>"#,
3379 kind = escape_html(k),
3380 ));
3381 }
3382 body.push_str("</tbody></table>");
3383 } else {
3384 body.push_str(
3385 r#"<p style="color:var(--ink-3);">No canonical events in the last 7 days. Quiet frontier or fresh seed.</p>"#,
3386 );
3387 }
3388 body.push_str("</div>");
3389
3390 let render_atom = |a: &crate::sources::EvidenceAtom| {
3391 format!(
3392 r#"<tr><td><code>{aid}</code></td><td><code>{fid}</code></td><td><a href="/review/locator-repair/{aid}">repair →</a></td></tr>"#,
3393 aid = escape_html(&a.id),
3394 fid = escape_html(&a.finding_id),
3395 )
3396 };
3397 let render_finding = |f: &FindingBundle, route: &str| {
3401 format!(
3402 r#"<tr><td><code>{fid}</code></td><td title="{full}">{txt}</td><td><a href="/review/{route}/{fid}">repair →</a></td></tr>"#,
3403 fid = escape_html(&f.id),
3404 txt = escape_html(&truncate(&f.assertion.text, 80)),
3405 full = escape_html(&f.assertion.text),
3406 )
3407 };
3408
3409 body.push_str(r#"<div class="wb-card"><h3>Locator gaps</h3><table class="wb-table"><thead><tr><th>atom</th><th>finding</th><th></th></tr></thead><tbody>"#);
3410 for a in &locator_gaps {
3411 body.push_str(&render_atom(a));
3412 }
3413 body.push_str("</tbody></table></div>");
3414
3415 body.push_str(r#"<div class="wb-card"><h3>Span gaps</h3><table class="wb-table"><thead><tr><th>finding</th><th>assertion</th><th></th></tr></thead><tbody>"#);
3416 for f in &span_gaps {
3417 body.push_str(&render_finding(f, "span-repair"));
3418 }
3419 body.push_str("</tbody></table></div>");
3420
3421 body.push_str(r#"<div class="wb-card"><h3>Entity gaps</h3><table class="wb-table"><thead><tr><th>finding</th><th>assertion</th><th></th></tr></thead><tbody>"#);
3422 for f in &entity_gaps {
3423 body.push_str(&render_finding(f, "entity-resolve"));
3424 }
3425 body.push_str("</tbody></table></div>");
3426
3427 body.push_str(r#"<div class="wb-card"><h3>Link gaps (findings without typed links)</h3><table class="wb-table"><thead><tr><th>finding</th><th>assertion</th></tr></thead><tbody>"#);
3428 for f in &link_gaps {
3429 body.push_str(&format!(
3430 r#"<tr><td><code>{fid}</code></td><td>{txt}</td></tr>"#,
3431 fid = escape_html(&f.id),
3432 txt = escape_html(&truncate(&f.assertion.text, 80)),
3433 ));
3434 }
3435 body.push_str("</tbody></table></div>");
3436
3437 body.push_str(r#"<div class="wb-card"><h3>Findings pending review</h3>"#);
3438 if promote_pending.is_empty() {
3439 body.push_str(
3440 r#"<p style="color:var(--ink-3);">No findings without a recorded review verdict. Every finding has been promoted to accepted-core, contested, needs_revision, or rejected.</p>"#,
3441 );
3442 } else {
3443 body.push_str(r#"<p style="color:var(--ink-3);font-size:0.86rem;">Each promote submission lands as a signed canonical `finding.review` event under the configured reviewer id. No bulk affordance; one finding per submission.</p>"#);
3444 body.push_str(r#"<table class="wb-table"><thead><tr><th>finding</th><th>assertion</th><th>source</th><th>state</th><th></th></tr></thead><tbody>"#);
3445 for f in &promote_pending {
3446 let state_label = match &f.flags.review_state {
3447 Some(crate::bundle::ReviewState::NeedsRevision) => "needs_revision",
3448 None => "(unset)",
3449 _ => "(other)",
3450 };
3451 let source_ref =
3455 if let Some(doi) = f.provenance.doi.as_deref().filter(|s| !s.is_empty()) {
3456 format!("<code>doi:{}</code>", escape_html(doi))
3457 } else if let Some(pmid) = f.provenance.pmid.as_deref().filter(|s| !s.is_empty()) {
3458 format!("<code>pmid:{}</code>", escape_html(pmid))
3459 } else {
3460 "<span style=\"color:var(--ink-3);\">none</span>".to_string()
3461 };
3462 let year_ref = f
3463 .provenance
3464 .year
3465 .map(|y| format!(" · {y}"))
3466 .unwrap_or_default();
3467 body.push_str(&format!(
3468 r#"<tr><td><code>{fid}</code></td><td title="{full}">{txt}</td><td>{src}{year}</td><td><code>{state}</code></td><td><a href="/review/promote/{fid}">promote →</a></td></tr>"#,
3469 fid = escape_html(&f.id),
3470 txt = escape_html(&truncate(&f.assertion.text, 80)),
3471 full = escape_html(&f.assertion.text),
3472 src = source_ref,
3473 year = year_ref,
3474 state = state_label,
3475 ));
3476 }
3477 body.push_str("</tbody></table>");
3478 }
3479 body.push_str("</div>");
3480
3481 body.push_str(r#"<div class="wb-card"><h3>Federation conflicts</h3>"#);
3482 if federation_conflicts.is_empty() {
3483 body.push_str(
3484 r#"<p style="color:var(--ink-3);">No frontier.conflict_detected events on this frontier yet. Conflicts surface here when `vela federation sync` produces divergence with a peer's view.</p>"#,
3485 );
3486 } else {
3487 body.push_str(r#"<p style="color:var(--ink-3);font-size:0.86rem;">Each conflict pairs a `frontier.conflict_detected` event (the original detection) with an optional `frontier.conflict_resolved` event (the reviewer's verdict). One resolution per detection; the original event is never modified.</p>"#);
3488 body.push_str(r#"<table class="wb-table"><thead><tr><th>peer</th><th>finding</th><th>kind</th><th>when</th><th>state</th><th></th></tr></thead><tbody>"#);
3489 let resolved_index: std::collections::HashSet<String> = project
3493 .events
3494 .iter()
3495 .filter(|e| e.kind == "frontier.conflict_resolved")
3496 .filter_map(|e| {
3497 e.payload
3498 .get("conflict_event_id")
3499 .and_then(|v| v.as_str())
3500 .map(str::to_string)
3501 })
3502 .collect();
3503 for ev in &federation_conflicts {
3504 let peer = ev
3505 .payload
3506 .get("peer_id")
3507 .and_then(|v| v.as_str())
3508 .unwrap_or("?")
3509 .to_string();
3510 let fid = ev
3511 .payload
3512 .get("finding_id")
3513 .and_then(|v| v.as_str())
3514 .unwrap_or("?")
3515 .to_string();
3516 let conflict_kind = ev
3517 .payload
3518 .get("kind")
3519 .and_then(|v| v.as_str())
3520 .unwrap_or("?")
3521 .to_string();
3522 let resolved = resolved_index.contains(&ev.id);
3523 let (state_label, action_cell) = if resolved {
3524 (
3525 "<code>resolved</code>",
3526 "<span style=\"color:var(--ink-3);\">recorded</span>".to_string(),
3527 )
3528 } else {
3529 (
3530 "<code>open</code>",
3531 format!(
3532 r#"<a href="/review/conflict-resolve/{cid}">resolve →</a>"#,
3533 cid = escape_html(&ev.id),
3534 ),
3535 )
3536 };
3537 body.push_str(&format!(
3538 r#"<tr><td><code>{peer}</code></td><td><code>{fid}</code></td><td>{kind}</td><td><code>{ts}</code></td><td>{state}</td><td>{action}</td></tr>"#,
3539 peer = escape_html(&peer),
3540 fid = escape_html(&fid),
3541 kind = escape_html(&conflict_kind),
3542 ts = escape_html(&ev.timestamp[..10.min(ev.timestamp.len())]),
3543 state = state_label,
3544 action = action_cell,
3545 ));
3546 }
3547 body.push_str("</tbody></table>");
3548 }
3549 body.push_str("</div>");
3550
3551 let html = shell(
3552 "review",
3553 "Inbox · Vela Workbench",
3554 "Workbench",
3555 "Inbox",
3556 &body,
3557 );
3558 Html(html).into_response()
3559}
3560
3561async fn page_review_locator_repair(
3562 AxumPath(atom_id): AxumPath<String>,
3563 State(state): State<AppState>,
3564 Query(q): Query<ErrorTokenQuery>,
3565) -> Response {
3566 let project = match repo::load_from_path(&state.repo_path) {
3567 Ok(p) => p,
3568 Err(e) => return error_page("review", "Could not load frontier", &e),
3569 };
3570 let Some(atom) = project.evidence_atoms.iter().find(|a| a.id == atom_id) else {
3571 return error_page("review", "Atom not found", &atom_id);
3572 };
3573 let parent_locator = project
3574 .sources
3575 .iter()
3576 .find(|s| s.id == atom.source_id)
3577 .map(|s| s.locator.clone())
3578 .unwrap_or_default();
3579 let cached = q
3582 .error
3583 .as_deref()
3584 .and_then(|tok| take_form_state(&state, tok));
3585 let (locator_val, reviewer_val, reason_val, banner) = match cached {
3586 Some(FormState::LocatorRepair {
3587 locator,
3588 reviewer,
3589 reason,
3590 error,
3591 ..
3592 }) => (locator, reviewer, reason, render_error_banner(&error)),
3593 _ => (
3594 parent_locator.clone(),
3595 default_reviewer(),
3596 "Mechanical evidence-atom locator repair from parent source.".to_string(),
3597 String::new(),
3598 ),
3599 };
3600 let body = format!(
3601 r#"{datalist}{banner}<div class="wb-card"><h3>Locator repair</h3>
3602<p>Atom <code>{aid}</code> on finding <code>{fid}</code>.</p>
3603<p>Parent source <code>{sid}</code> carries locator <code>{loc}</code>.</p>
3604<form method="post" action="/review/locator-repair">
3605<input type="hidden" name="atom_id" value="{aid_safe}">
3606<p><label>Locator <input name="locator" value="{loc_safe}" style="width:36rem;"></label></p>
3607<p><label>Reviewer <input name="reviewer" value="{rev}" list="vela-actors"></label></p>
3608<p><label>Reason <input name="reason" value="{reason_safe}" style="width:36rem;"></label></p>
3609<p><button type="submit">Apply</button> <a href="/review/inbox" style="margin-left:1rem;color:var(--ink-3);">Cancel</a></p>
3610</form></div>"#,
3611 datalist = actor_datalist(&project),
3612 banner = banner,
3613 aid = escape_html(&atom.id),
3614 aid_safe = escape_html(&atom.id),
3615 fid = escape_html(&atom.finding_id),
3616 sid = escape_html(&atom.source_id),
3617 loc = escape_html(&parent_locator),
3618 loc_safe = escape_html(&locator_val),
3619 rev = escape_html(&reviewer_val),
3620 reason_safe = escape_html(&reason_val),
3621 );
3622 let html = shell(
3623 "review",
3624 "Locator repair · Vela Workbench",
3625 "Workbench",
3626 "Locator repair",
3627 &body,
3628 );
3629 Html(html).into_response()
3630}
3631
3632async fn post_review_locator_repair(
3633 State(state): State<AppState>,
3634 Form(form): Form<LocatorRepairForm>,
3635) -> Response {
3636 match state::repair_evidence_atom_locator(
3637 &state.repo_path,
3638 &form.atom_id,
3639 Some(&form.locator),
3640 &form.reviewer,
3641 &form.reason,
3642 true,
3643 ) {
3644 Ok(_) => Redirect::to("/review/inbox").into_response(),
3645 Err(e) => {
3646 let token = store_form_state(
3649 &state,
3650 FormState::LocatorRepair {
3651 atom_id: form.atom_id.clone(),
3652 locator: form.locator.clone(),
3653 reviewer: form.reviewer.clone(),
3654 reason: form.reason.clone(),
3655 error: e,
3656 },
3657 );
3658 let url = format!(
3659 "/review/locator-repair/{aid}?error={tok}",
3660 aid = urlencode_path(&form.atom_id),
3661 tok = token,
3662 );
3663 Redirect::to(&url).into_response()
3664 }
3665 }
3666}
3667
3668async fn page_review_span_repair(
3669 AxumPath(finding_id): AxumPath<String>,
3670 State(state): State<AppState>,
3671 Query(q): Query<ErrorTokenQuery>,
3672) -> Response {
3673 let project = match repo::load_from_path(&state.repo_path) {
3674 Ok(p) => p,
3675 Err(e) => return error_page("review", "Could not load frontier", &e),
3676 };
3677 let Some(f) = project.findings.iter().find(|f| f.id == finding_id) else {
3678 return error_page("review", "Finding not found", &finding_id);
3679 };
3680 let cache_text = lookup_cached_abstract(&state.repo_path, f);
3682 let pre_text = cache_text.as_deref().unwrap_or("");
3683 let cache_note = if cache_text.is_some() {
3684 r#"<p style="color:var(--ink-3);font-size:0.86rem;">text pre-filled from sources/cache/</p>"#
3685 } else {
3686 ""
3687 };
3688 let cached = q
3690 .error
3691 .as_deref()
3692 .and_then(|tok| take_form_state(&state, tok));
3693 let (section_val, text_val, reviewer_val, reason_val, banner) = match cached {
3694 Some(FormState::SpanRepair {
3695 section,
3696 text,
3697 reviewer,
3698 reason,
3699 error,
3700 ..
3701 }) => (section, text, reviewer, reason, render_error_banner(&error)),
3702 _ => (
3703 "abstract".to_string(),
3704 pre_text.to_string(),
3705 default_reviewer(),
3706 "Reviewer-verified evidence span.".to_string(),
3707 String::new(),
3708 ),
3709 };
3710 let body = format!(
3711 r#"{banner}<div class="wb-card"><h3>Span repair</h3>
3712<p>Finding <code>{fid}</code>.</p>
3713<p style="font-size:0.92rem;color:var(--ink-2);">{assertion}</p>
3714<form method="post" action="/review/span-repair">
3715<input type="hidden" name="finding_id" value="{fid_safe}">
3716<p><label>Section <input name="section" value="{section_safe}"></label></p>
3717<p><label>Text<br><textarea name="text" rows="6" style="width:36rem;">{text}</textarea></label></p>
3718{cache_note}
3719<p><label>Reviewer <input name="reviewer" value="{rev}" list="vela-actors"></label></p>
3720<p><label>Reason <input name="reason" value="{reason_safe}" style="width:36rem;"></label></p>
3721<p><button type="submit">Apply</button> <a href="/review/inbox" style="margin-left:1rem;color:var(--ink-3);">Cancel</a></p>
3722</form></div>"#,
3723 banner = banner,
3724 fid = escape_html(&f.id),
3725 fid_safe = escape_html(&f.id),
3726 assertion = escape_html(&f.assertion.text),
3727 section_safe = escape_html(§ion_val),
3728 text = escape_html(&text_val),
3729 cache_note = cache_note,
3730 rev = escape_html(&reviewer_val),
3731 reason_safe = escape_html(&reason_val),
3732 );
3733 let body = format!("{}{}", actor_datalist(&project), body);
3734 let html = shell(
3735 "review",
3736 "Span repair · Vela Workbench",
3737 "Workbench",
3738 "Span repair",
3739 &body,
3740 );
3741 Html(html).into_response()
3742}
3743
3744async fn post_review_span_repair(
3745 State(state): State<AppState>,
3746 Form(form): Form<SpanRepairForm>,
3747) -> Response {
3748 match state::repair_finding_span(
3749 &state.repo_path,
3750 &form.finding_id,
3751 &form.section,
3752 &form.text,
3753 &form.reviewer,
3754 &form.reason,
3755 true,
3756 ) {
3757 Ok(_) => Redirect::to("/review/inbox").into_response(),
3758 Err(e) => {
3759 let token = store_form_state(
3760 &state,
3761 FormState::SpanRepair {
3762 finding_id: form.finding_id.clone(),
3763 section: form.section.clone(),
3764 text: form.text.clone(),
3765 reviewer: form.reviewer.clone(),
3766 reason: form.reason.clone(),
3767 error: e,
3768 },
3769 );
3770 let url = format!(
3771 "/review/span-repair/{fid}?error={tok}",
3772 fid = urlencode_path(&form.finding_id),
3773 tok = token,
3774 );
3775 Redirect::to(&url).into_response()
3776 }
3777 }
3778}
3779
3780async fn page_review_entity_resolve(
3781 AxumPath(finding_id): AxumPath<String>,
3782 State(state): State<AppState>,
3783 Query(q): Query<ErrorTokenQuery>,
3784) -> Response {
3785 let project = match repo::load_from_path(&state.repo_path) {
3786 Ok(p) => p,
3787 Err(e) => return error_page("review", "Could not load frontier", &e),
3788 };
3789 let Some(f) = project.findings.iter().find(|f| f.id == finding_id) else {
3790 return error_page("review", "Finding not found", &finding_id);
3791 };
3792 let unresolved: Vec<_> = f
3793 .assertion
3794 .entities
3795 .iter()
3796 .filter(|e| e.needs_review)
3797 .collect();
3798 if unresolved.is_empty() {
3799 return error_page(
3800 "review",
3801 "Nothing to resolve",
3802 "All entities on this finding are already resolved.",
3803 );
3804 }
3805 let cached_entity_state = q
3809 .error
3810 .as_deref()
3811 .and_then(|tok| take_form_state(&state, tok));
3812 let cached = match cached_entity_state {
3813 Some(FormState::EntityResolve {
3814 entity_name,
3815 source,
3816 id,
3817 confidence,
3818 matched_name,
3819 reviewer,
3820 reason,
3821 error,
3822 ..
3823 }) => Some((
3824 entity_name,
3825 source,
3826 id,
3827 confidence,
3828 matched_name,
3829 reviewer,
3830 reason,
3831 error,
3832 )),
3833 _ => None,
3834 };
3835 let banner = cached
3836 .as_ref()
3837 .map(|c| render_error_banner(&c.7))
3838 .unwrap_or_default();
3839 let mut forms = String::new();
3840 let source_options = |selected: &str| -> String {
3841 let opts = [
3842 ("hgnc", "HGNC (gene)"),
3843 ("uniprot", "UniProt (protein)"),
3844 ("mesh", "MeSH (disease/concept)"),
3845 ("uberon", "UBERON (anatomy)"),
3846 ("cl", "CL (cell type)"),
3847 ("drugbank", "DrugBank (compound)"),
3848 ("vela", "vela: (custom)"),
3849 ];
3850 let mut out = String::new();
3851 for (val, label) in opts {
3852 let sel = if val == selected { " selected" } else { "" };
3853 out.push_str(&format!(r#"<option value="{val}"{sel}>{label}</option>"#));
3854 }
3855 out
3856 };
3857 for ent in &unresolved {
3858 let (source_val, id_val, conf_val, matched_val, reviewer_val, reason_val) =
3859 match cached.as_ref() {
3860 Some(c) if c.0 == ent.name => (
3861 c.1.clone(),
3862 c.2.clone(),
3863 c.3,
3864 c.4.clone().unwrap_or_default(),
3865 c.5.clone(),
3866 c.6.clone(),
3867 ),
3868 _ => (
3869 "hgnc".to_string(),
3870 String::new(),
3871 0.95,
3872 String::new(),
3873 default_reviewer(),
3874 "Resolved against canonical biological databases.".to_string(),
3875 ),
3876 };
3877 forms.push_str(&format!(
3878 r#"<div class="wb-card"><h3>{name} <span class="wb-chip wb-chip--warn">{etype}</span></h3>
3879<form method="post" action="/review/entity-resolve">
3880<input type="hidden" name="finding_id" value="{fid}">
3881<input type="hidden" name="entity_name" value="{name_safe}">
3882<p><label>Source <select name="source">
3883{src_opts}
3884</select></label></p>
3885<p><label>ID <input name="id" value="{id_val}" placeholder="e.g. 8804 or P05067"></label></p>
3886<p><label>Confidence <input name="confidence" type="number" min="0" max="1" step="0.01" value="{conf_val}"></label></p>
3887<p><label>Matched name <input name="matched_name" value="{matched_val}" placeholder="optional"></label></p>
3888<input type="hidden" name="resolution_method" value="manual">
3889<p><label>Reviewer <input name="reviewer" value="{rev}" list="vela-actors"></label></p>
3890<p><label>Reason <input name="reason" value="{reason_safe}" style="width:36rem;"></label></p>
3891<p><button type="submit">Apply</button> <a href="/review/inbox" style="margin-left:1rem;color:var(--ink-3);">Cancel</a></p>
3892</form></div>"#,
3893 name = escape_html(&ent.name),
3894 name_safe = escape_html(&ent.name),
3895 etype = escape_html(&ent.entity_type),
3896 fid = escape_html(&f.id),
3897 src_opts = source_options(&source_val),
3898 id_val = escape_html(&id_val),
3899 conf_val = conf_val,
3900 matched_val = escape_html(&matched_val),
3901 rev = escape_html(&reviewer_val),
3902 reason_safe = escape_html(&reason_val),
3903 ));
3904 }
3905 let body = format!(
3906 r#"{banner}<div class="wb-card"><h3>Entity resolution for <code>{fid}</code></h3>
3907<p style="font-size:0.92rem;color:var(--ink-2);">{assertion}</p>
3908<p>{n} unresolved entities below.</p>
3909</div>{forms}"#,
3910 banner = banner,
3911 fid = escape_html(&f.id),
3912 assertion = escape_html(&f.assertion.text),
3913 n = unresolved.len(),
3914 forms = forms,
3915 );
3916 let body = format!("{}{}", actor_datalist(&project), body);
3917 let html = shell(
3918 "review",
3919 "Entity resolve · Vela Workbench",
3920 "Workbench",
3921 "Entity resolve",
3922 &body,
3923 );
3924 Html(html).into_response()
3925}
3926
3927async fn post_review_entity_resolve(
3928 State(state): State<AppState>,
3929 Form(form): Form<EntityResolveForm>,
3930) -> Response {
3931 match state::resolve_finding_entity(
3932 &state.repo_path,
3933 &form.finding_id,
3934 &form.entity_name,
3935 &form.source,
3936 &form.id,
3937 form.confidence,
3938 form.matched_name.as_deref(),
3939 &form.resolution_method,
3940 &form.reviewer,
3941 &form.reason,
3942 true,
3943 ) {
3944 Ok(_) => Redirect::to("/review/inbox").into_response(),
3945 Err(e) => {
3946 let token = store_form_state(
3947 &state,
3948 FormState::EntityResolve {
3949 finding_id: form.finding_id.clone(),
3950 entity_name: form.entity_name.clone(),
3951 source: form.source.clone(),
3952 id: form.id.clone(),
3953 confidence: form.confidence,
3954 matched_name: form.matched_name.clone(),
3955 resolution_method: form.resolution_method.clone(),
3956 reviewer: form.reviewer.clone(),
3957 reason: form.reason.clone(),
3958 error: e,
3959 },
3960 );
3961 let url = format!(
3962 "/review/entity-resolve/{fid}?error={tok}",
3963 fid = urlencode_path(&form.finding_id),
3964 tok = token,
3965 );
3966 Redirect::to(&url).into_response()
3967 }
3968 }
3969}
3970
3971async fn page_review_promote(
3975 AxumPath(finding_id): AxumPath<String>,
3976 State(state): State<AppState>,
3977 Query(q): Query<ErrorTokenQuery>,
3978) -> Response {
3979 let project = match repo::load_from_path(&state.repo_path) {
3980 Ok(p) => p,
3981 Err(e) => return error_page("review", "Could not load frontier", &e),
3982 };
3983 let Some(f) = project.findings.iter().find(|f| f.id == finding_id) else {
3984 return error_page("review", "Finding not found", &finding_id);
3985 };
3986 let current_state = match &f.flags.review_state {
3987 Some(crate::bundle::ReviewState::Accepted) => "accepted",
3988 Some(crate::bundle::ReviewState::Contested) => "contested",
3989 Some(crate::bundle::ReviewState::NeedsRevision) => "needs_revision",
3990 Some(crate::bundle::ReviewState::Rejected) => "rejected",
3991 None => "(unset)",
3992 };
3993 let assertion = escape_html(&f.assertion.text);
3994 let confidence = f.confidence.score;
3995 let source_block = {
4000 let mut parts: Vec<String> = Vec::new();
4001 if let Some(doi) = f.provenance.doi.as_deref() {
4002 parts.push(format!("<code>doi:{}</code>", escape_html(doi)));
4003 }
4004 if let Some(pmid) = f.provenance.pmid.as_deref() {
4005 parts.push(format!("<code>pmid:{}</code>", escape_html(pmid)));
4006 }
4007 if let Some(y) = f.provenance.year {
4008 parts.push(format!("{y}"));
4009 }
4010 if let Some(j) = f.provenance.journal.as_deref()
4011 && !j.is_empty()
4012 {
4013 parts.push(escape_html(j));
4014 }
4015 if parts.is_empty() {
4016 "<span style=\"color:var(--ink-3);\">no source metadata</span>".to_string()
4017 } else {
4018 parts.join(" · ")
4019 }
4020 };
4021 let mut spans_block = String::new();
4022 if f.evidence.evidence_spans.is_empty() {
4023 spans_block.push_str(
4024 r#"<p style="color:var(--ink-3);font-size:0.86rem;">No evidence_spans attached. Repair the span via /review/span-repair before promoting if the source has retrievable text.</p>"#,
4025 );
4026 } else {
4027 spans_block.push_str(r#"<p style="color:var(--ink-3);font-size:0.86rem;margin-top:0.6rem;">Verbatim evidence spans attached to this finding. The reviewer's verdict should be readable as a one-step inference from these spans.</p>"#);
4028 for s in &f.evidence.evidence_spans {
4032 let section = s
4033 .get("section")
4034 .and_then(|v| v.as_str())
4035 .unwrap_or("(unsectioned)");
4036 let text = s.get("text").and_then(|v| v.as_str()).unwrap_or("");
4037 if text.is_empty() {
4038 continue;
4039 }
4040 spans_block.push_str(&format!(
4041 r#"<blockquote style="color:var(--ink-2);font-size:0.9rem;margin:0.4rem 0 0.6rem 0;border-left:2px solid var(--ink-4);padding-left:0.8rem;"><strong>[{section}]</strong> {text}</blockquote>"#,
4042 section = escape_html(section),
4043 text = escape_html(text),
4044 ));
4045 }
4046 }
4047 let cached = q
4049 .error
4050 .as_deref()
4051 .and_then(|tok| take_form_state(&state, tok));
4052 let (status_val, reviewer_val, reason_val, banner) = match cached {
4053 Some(FormState::Promote {
4054 status,
4055 reviewer,
4056 reason,
4057 error,
4058 ..
4059 }) => (status, reviewer, reason, render_error_banner(&error)),
4060 _ => (
4061 "accepted".to_string(),
4062 default_reviewer(),
4063 String::new(),
4064 String::new(),
4065 ),
4066 };
4067 let status_options = {
4068 let opts = ["accepted", "contested", "needs_revision", "rejected"];
4069 let mut out = String::new();
4070 for v in opts {
4071 let sel = if v == status_val { " selected" } else { "" };
4072 out.push_str(&format!(r#"<option value="{v}"{sel}>{v}</option>"#));
4073 }
4074 out
4075 };
4076 let body = format!(
4077 r#"{banner}<div class="wb-card"><h3>Promote to accepted-core</h3>
4078<p>Finding <code>{fid}</code> · <a href="/findings/{fid}">inspect full record →</a></p>
4079<p>Source: {src}</p>
4080<p>Current review state: <code>{current}</code> · raw confidence: <code>{conf:.2}</code></p>
4081<p style="font-weight:500;margin-top:0.6rem;">Assertion</p>
4082<blockquote style="color:var(--ink-1);font-size:0.95rem;margin:0.2rem 0 0.6rem 0;border-left:2px solid var(--ink-4);padding-left:0.8rem;">{assertion}</blockquote>
4083<p style="font-weight:500;margin-top:0.6rem;">Evidence</p>
4084{spans_block}
4085<form method="post" action="/review/promote" style="margin-top:0.6rem;">
4086<input type="hidden" name="finding_id" value="{fid_safe}">
4087<p><label>Status
4088<select name="status">
4089{status_options}
4090</select>
4091</label></p>
4092<p><label>Reviewer <input name="reviewer" value="{rev}" list="vela-actors" required></label></p>
4093<p><label>Reason <input name="reason" value="{reason_safe}" placeholder="Reviewer's verdict rationale (cite the evidence span and the calibration anchor)" style="width:36rem;" required></label></p>
4094<p style="color:var(--ink-3);font-size:0.86rem;">Submission lands as a signed canonical `finding.review` event under the configured reviewer id. No silent edits; the event is replayable from `.vela/events/`.</p>
4095<p><button type="submit">Apply</button> <a href="/review/inbox" style="margin-left:1rem;color:var(--ink-3);">Cancel</a></p>
4096</form></div>"#,
4097 banner = banner,
4098 fid = escape_html(&f.id),
4099 fid_safe = escape_html(&f.id),
4100 current = current_state,
4101 conf = confidence,
4102 assertion = assertion,
4103 src = source_block,
4104 spans_block = spans_block,
4105 status_options = status_options,
4106 rev = escape_html(&reviewer_val),
4107 reason_safe = escape_html(&reason_val),
4108 );
4109 let body = format!("{}{}", actor_datalist(&project), body);
4110 let html = shell(
4111 "review",
4112 "Promote to accepted-core · Vela Workbench",
4113 "Workbench",
4114 "Promote to accepted-core",
4115 &body,
4116 );
4117 Html(html).into_response()
4118}
4119
4120async fn post_review_promote(
4121 State(state): State<AppState>,
4122 Form(form): Form<PromoteForm>,
4123) -> Response {
4124 let options = state::ReviewOptions {
4125 status: form.status.clone(),
4126 reason: form.reason.clone(),
4127 reviewer: form.reviewer.clone(),
4128 };
4129 match state::review_finding(&state.repo_path, &form.finding_id, options, true) {
4130 Ok(_) => Redirect::to("/review/inbox").into_response(),
4131 Err(e) => {
4132 let token = store_form_state(
4133 &state,
4134 FormState::Promote {
4135 finding_id: form.finding_id.clone(),
4136 status: form.status.clone(),
4137 reviewer: form.reviewer.clone(),
4138 reason: form.reason.clone(),
4139 error: e,
4140 },
4141 );
4142 let url = format!(
4143 "/review/promote/{fid}?error={tok}",
4144 fid = urlencode_path(&form.finding_id),
4145 tok = token,
4146 );
4147 Redirect::to(&url).into_response()
4148 }
4149 }
4150}
4151
4152async fn page_review_conflict_resolve(
4154 AxumPath(conflict_event_id): AxumPath<String>,
4155 State(state): State<AppState>,
4156 Query(q): Query<ErrorTokenQuery>,
4157) -> Response {
4158 let project = match repo::load_from_path(&state.repo_path) {
4159 Ok(p) => p,
4160 Err(e) => return error_page("review", "Could not load frontier", &e),
4161 };
4162 let Some(conflict) = project
4163 .events
4164 .iter()
4165 .find(|e| e.id == conflict_event_id && e.kind == "frontier.conflict_detected")
4166 else {
4167 return error_page(
4168 "review",
4169 "Conflict event not found",
4170 &format!(
4171 "No `frontier.conflict_detected` event with id '{conflict_event_id}' on this frontier."
4172 ),
4173 );
4174 };
4175 let already_resolved = project.events.iter().any(|e| {
4179 e.kind == "frontier.conflict_resolved"
4180 && e.payload.get("conflict_event_id").and_then(|v| v.as_str())
4181 == Some(&conflict_event_id)
4182 });
4183 if already_resolved {
4184 return error_page(
4185 "review",
4186 "Conflict already resolved",
4187 &format!(
4188 "Conflict event '{conflict_event_id}' already has a recorded resolution. The resolution event lives in the log; reviewers do not amend prior verdicts."
4189 ),
4190 );
4191 }
4192 let peer = conflict
4193 .payload
4194 .get("peer_id")
4195 .and_then(|v| v.as_str())
4196 .unwrap_or("?")
4197 .to_string();
4198 let finding_ref = conflict
4199 .payload
4200 .get("finding_id")
4201 .and_then(|v| v.as_str())
4202 .unwrap_or("?")
4203 .to_string();
4204 let kind = conflict
4205 .payload
4206 .get("kind")
4207 .and_then(|v| v.as_str())
4208 .unwrap_or("?")
4209 .to_string();
4210 let detail = conflict
4211 .payload
4212 .get("detail")
4213 .and_then(|v| v.as_str())
4214 .unwrap_or("")
4215 .to_string();
4216 let cached = q
4218 .error
4219 .as_deref()
4220 .and_then(|tok| take_form_state(&state, tok));
4221 let (note_val, winning_val, reviewer_val, banner) = match cached {
4222 Some(FormState::ConflictResolve {
4223 resolution_note,
4224 winning_proposal_id,
4225 reviewer,
4226 error,
4227 ..
4228 }) => (
4229 resolution_note,
4230 winning_proposal_id.unwrap_or_default(),
4231 reviewer,
4232 render_error_banner(&error),
4233 ),
4234 _ => (
4235 String::new(),
4236 String::new(),
4237 default_reviewer(),
4238 String::new(),
4239 ),
4240 };
4241 let body = format!(
4242 r#"{banner}<div class="wb-card"><h3>Resolve federation conflict</h3>
4243<p>Conflict event <code>{cid}</code></p>
4244<p>Detected by sync with peer <code>{peer}</code> on <code>{fid}</code> with kind <code>{kind}</code>.</p>
4245<p style="color:var(--ink-2);font-size:0.92rem;">{detail}</p>
4246<form method="post" action="/review/conflict-resolve">
4247<input type="hidden" name="conflict_event_id" value="{cid_safe}">
4248<p><label>Resolution note <input name="resolution_note" value="{note_safe}" placeholder="Reviewer's verdict and rationale" style="width:36rem;" required></label></p>
4249<p><label>Winning proposal id (optional) <input name="winning_proposal_id" value="{winning_safe}" placeholder="vpr_..." style="width:24rem;"></label></p>
4250<p><label>Reviewer <input name="reviewer" value="{rev}" list="vela-actors"></label></p>
4251<p style="color:var(--ink-3);font-size:0.86rem;">Submission lands as a signed canonical `frontier.conflict_resolved` event paired with the conflict by id. The original conflict event is not modified; one resolution per conflict.</p>
4252<p><button type="submit">Apply</button> <a href="/review/inbox" style="margin-left:1rem;color:var(--ink-3);">Cancel</a></p>
4253</form></div>"#,
4254 banner = banner,
4255 cid = escape_html(&conflict_event_id),
4256 cid_safe = escape_html(&conflict_event_id),
4257 peer = escape_html(&peer),
4258 fid = escape_html(&finding_ref),
4259 kind = escape_html(&kind),
4260 detail = escape_html(&detail),
4261 note_safe = escape_html(¬e_val),
4262 winning_safe = escape_html(&winning_val),
4263 rev = escape_html(&reviewer_val),
4264 );
4265 let body = format!("{}{}", actor_datalist(&project), body);
4266 let html = shell(
4267 "review",
4268 "Resolve conflict · Vela Workbench",
4269 "Workbench",
4270 "Resolve conflict",
4271 &body,
4272 );
4273 Html(html).into_response()
4274}
4275
4276async fn post_review_conflict_resolve(
4277 State(state): State<AppState>,
4278 Form(form): Form<ConflictResolveForm>,
4279) -> Response {
4280 let winning_proposal_id = form
4281 .winning_proposal_id
4282 .as_deref()
4283 .map(str::trim)
4284 .filter(|s| !s.is_empty());
4285 match state::resolve_frontier_conflict(
4286 &state.repo_path,
4287 &form.conflict_event_id,
4288 &form.resolution_note,
4289 &form.reviewer,
4290 winning_proposal_id,
4291 true,
4292 ) {
4293 Ok(_) => Redirect::to("/review/inbox").into_response(),
4294 Err(e) => {
4295 let token = store_form_state(
4296 &state,
4297 FormState::ConflictResolve {
4298 conflict_event_id: form.conflict_event_id.clone(),
4299 resolution_note: form.resolution_note.clone(),
4300 winning_proposal_id: form.winning_proposal_id.clone(),
4301 reviewer: form.reviewer.clone(),
4302 error: e,
4303 },
4304 );
4305 let url = format!(
4306 "/review/conflict-resolve/{cid}?error={tok}",
4307 cid = urlencode_path(&form.conflict_event_id),
4308 tok = token,
4309 );
4310 Redirect::to(&url).into_response()
4311 }
4312 }
4313}
4314
4315async fn page_review_replication_add(
4322 AxumPath(finding_id): AxumPath<String>,
4323 State(state): State<AppState>,
4324 Query(error_q): Query<ErrorTokenQuery>,
4325) -> Response {
4326 let project = match repo::load_from_path(&state.repo_path) {
4327 Ok(p) => p,
4328 Err(e) => return error_page("review", "Could not load frontier", &e),
4329 };
4330 let Some(f) = project.findings.iter().find(|f| f.id == finding_id) else {
4331 return error_page("review", "Finding not found", &finding_id);
4332 };
4333 let cached = error_q
4334 .error
4335 .as_deref()
4336 .and_then(|tok| take_form_state(&state, tok));
4337 let (outcome, attempted_by, conditions_text, source_title, doi, pmid, note, error_html) =
4338 if let Some(FormState::ReplicationAdd {
4339 outcome,
4340 attempted_by,
4341 conditions_text,
4342 source_title,
4343 doi,
4344 pmid,
4345 note,
4346 error,
4347 ..
4348 }) = cached
4349 {
4350 (
4351 outcome,
4352 attempted_by,
4353 conditions_text,
4354 source_title,
4355 doi,
4356 pmid,
4357 note,
4358 render_error_banner(&error),
4359 )
4360 } else {
4361 (
4362 "replicated".to_string(),
4363 default_reviewer(),
4364 String::new(),
4365 String::new(),
4366 String::new(),
4367 String::new(),
4368 String::new(),
4369 String::new(),
4370 )
4371 };
4372 let assertion = escape_html(&f.assertion.text);
4373 let body = format!(
4374 r#"{datalist}{error_html}<div class="wb-card"><h3>Add replication</h3>
4375<p>Finding <code>{fid}</code> · <a href="/findings/{fid}">inspect full record →</a></p>
4376<blockquote style="color:var(--ink-2);font-size:0.92rem;margin:0.4rem 0 0.8rem 0;border-left:2px solid var(--ink-4);padding-left:0.8rem;">{assertion}</blockquote>
4377<form method="post" action="/review/replication-add" style="margin-top:0.6rem;">
4378<input type="hidden" name="finding_id" value="{fid_safe}">
4379<p><label>Outcome
4380<select name="outcome">
4381<option value="replicated"{sel_rep}>replicated</option>
4382<option value="failed"{sel_fail}>failed</option>
4383<option value="partial"{sel_part}>partial</option>
4384<option value="inconclusive"{sel_inc}>inconclusive</option>
4385</select>
4386</label></p>
4387<p><label>Attempted by <input name="attempted_by" value="{attempted_by_safe}" list="vela-actors" required></label></p>
4388<p><label>Conditions <input name="conditions_text" value="{conditions_safe}" placeholder="model system, species, in vitro/vivo, dosing" style="width:36rem;" required></label></p>
4389<p><label>Source title <input name="source_title" value="{source_title_safe}" placeholder="Replicating paper or lab notebook" style="width:36rem;" required></label></p>
4390<p><label>DOI <input name="doi" value="{doi_safe}" placeholder="10.1038/..."></label> <label>PMID <input name="pmid" value="{pmid_safe}"></label></p>
4391<p><label>Note <input name="note" value="{note_safe}" placeholder="Reviewer note (esp. partial/inconclusive outcomes)" style="width:36rem;"></label></p>
4392<p style="color:var(--ink-3);font-size:0.86rem;">Submission lands as a signed canonical `replication.deposited` event under the configured reviewer id. The substrate refuses duplicate deposits at the content-addressed `vrep_*` id.</p>
4393<p><button type="submit">Apply</button> <a href="/review/inbox" style="margin-left:1rem;color:var(--ink-3);">Cancel</a></p>
4394</form></div>"#,
4395 datalist = actor_datalist(&project),
4396 error_html = error_html,
4397 fid = escape_html(&f.id),
4398 fid_safe = escape_html(&f.id),
4399 assertion = assertion,
4400 attempted_by_safe = escape_html(&attempted_by),
4401 conditions_safe = escape_html(&conditions_text),
4402 source_title_safe = escape_html(&source_title),
4403 doi_safe = escape_html(&doi),
4404 pmid_safe = escape_html(&pmid),
4405 note_safe = escape_html(¬e),
4406 sel_rep = if outcome == "replicated" {
4407 " selected"
4408 } else {
4409 ""
4410 },
4411 sel_fail = if outcome == "failed" { " selected" } else { "" },
4412 sel_part = if outcome == "partial" {
4413 " selected"
4414 } else {
4415 ""
4416 },
4417 sel_inc = if outcome == "inconclusive" {
4418 " selected"
4419 } else {
4420 ""
4421 },
4422 );
4423 let html = shell(
4424 "review",
4425 "Add replication · Vela Workbench",
4426 "Workbench",
4427 "Add replication",
4428 &body,
4429 );
4430 Html(html).into_response()
4431}
4432
4433async fn post_review_replication_add(
4434 State(state): State<AppState>,
4435 Form(form): Form<ReplicationAddForm>,
4436) -> Response {
4437 use crate::bundle::{Conditions, Evidence, Extraction, Provenance, Replication};
4438
4439 let evidence = Evidence {
4440 evidence_type: "experimental".to_string(),
4441 model_system: String::new(),
4442 species: None,
4443 method: "manual".to_string(),
4444 sample_size: None,
4445 effect_size: None,
4446 p_value: None,
4447 replicated: form.outcome == "replicated",
4448 replication_count: None,
4449 evidence_spans: Vec::new(),
4450 };
4451 let lower = form.conditions_text.to_lowercase();
4452 let conditions = Conditions {
4453 text: form.conditions_text.clone(),
4454 species_verified: Vec::new(),
4455 species_unverified: Vec::new(),
4456 in_vitro: lower.contains("in vitro"),
4457 in_vivo: lower.contains("in vivo"),
4458 human_data: lower.contains("human"),
4459 clinical_trial: lower.contains("clinical trial") || lower.contains("phase"),
4460 concentration_range: None,
4461 duration: None,
4462 age_group: None,
4463 cell_type: None,
4464 };
4465 let provenance = Provenance {
4466 title: form.source_title.clone(),
4467 source_type: "lab_notebook".to_string(),
4468 doi: if form.doi.trim().is_empty() {
4469 None
4470 } else {
4471 Some(form.doi.trim().to_string())
4472 },
4473 pmid: if form.pmid.trim().is_empty() {
4474 None
4475 } else {
4476 Some(form.pmid.trim().to_string())
4477 },
4478 pmc: None,
4479 openalex_id: None,
4480 url: None,
4481 authors: Vec::new(),
4482 year: None,
4483 journal: None,
4484 license: None,
4485 publisher: None,
4486 funders: Vec::new(),
4487 extraction: Extraction::default(),
4488 review: None,
4489 citation_count: None,
4490 };
4491 let rep = Replication::new(
4492 form.finding_id.clone(),
4493 form.attempted_by.clone(),
4494 form.outcome.clone(),
4495 evidence,
4496 conditions,
4497 provenance,
4498 form.note.clone(),
4499 );
4500 match state::deposit_replication(
4501 &state.repo_path,
4502 rep,
4503 &form.attempted_by,
4504 "Replication deposit via local Workbench",
4505 ) {
4506 Ok(_) => Redirect::to("/review/inbox").into_response(),
4507 Err(e) => {
4508 let token = store_form_state(
4509 &state,
4510 FormState::ReplicationAdd {
4511 finding_id: form.finding_id.clone(),
4512 outcome: form.outcome.clone(),
4513 attempted_by: form.attempted_by.clone(),
4514 conditions_text: form.conditions_text.clone(),
4515 source_title: form.source_title.clone(),
4516 doi: form.doi.clone(),
4517 pmid: form.pmid.clone(),
4518 note: form.note.clone(),
4519 error: e,
4520 },
4521 );
4522 let url = format!(
4523 "/review/replication-add/{fid}?error={tok}",
4524 fid = urlencode_path(&form.finding_id),
4525 tok = token,
4526 );
4527 Redirect::to(&url).into_response()
4528 }
4529 }
4530}
4531
4532async fn page_review_prediction_add(
4538 AxumPath(finding_id): AxumPath<String>,
4539 State(state): State<AppState>,
4540 Query(error_q): Query<ErrorTokenQuery>,
4541) -> Response {
4542 let project = match repo::load_from_path(&state.repo_path) {
4543 Ok(p) => p,
4544 Err(e) => return error_page("review", "Could not load frontier", &e),
4545 };
4546 let Some(f) = project.findings.iter().find(|f| f.id == finding_id) else {
4547 return error_page("review", "Finding not found", &finding_id);
4548 };
4549 let cached = error_q
4550 .error
4551 .as_deref()
4552 .and_then(|tok| take_form_state(&state, tok));
4553 let (
4554 claim_text,
4555 resolves_by,
4556 resolution_criterion,
4557 expected_outcome,
4558 made_by,
4559 confidence,
4560 conditions_text,
4561 error_html,
4562 ) = if let Some(FormState::PredictionAdd {
4563 claim_text,
4564 resolves_by,
4565 resolution_criterion,
4566 expected_outcome,
4567 made_by,
4568 confidence,
4569 conditions_text,
4570 error,
4571 ..
4572 }) = cached
4573 {
4574 (
4575 claim_text,
4576 resolves_by,
4577 resolution_criterion,
4578 expected_outcome,
4579 made_by,
4580 confidence,
4581 conditions_text,
4582 render_error_banner(&error),
4583 )
4584 } else {
4585 (
4586 String::new(),
4587 String::new(),
4588 String::new(),
4589 "affirmed".to_string(),
4590 default_reviewer(),
4591 0.7,
4592 String::new(),
4593 String::new(),
4594 )
4595 };
4596 let assertion = escape_html(&f.assertion.text);
4597 let body = format!(
4598 r#"{datalist}{error_html}<div class="wb-card"><h3>Add prediction</h3>
4599<p>Finding <code>{fid}</code> · <a href="/findings/{fid}">inspect full record →</a></p>
4600<blockquote style="color:var(--ink-2);font-size:0.92rem;margin:0.4rem 0 0.8rem 0;border-left:2px solid var(--ink-4);padding-left:0.8rem;">{assertion}</blockquote>
4601<form method="post" action="/review/prediction-add" style="margin-top:0.6rem;">
4602<input type="hidden" name="finding_id" value="{fid_safe}">
4603<p><label>Falsifiable claim <input name="claim_text" value="{claim_safe}" placeholder="What you expect to be true" style="width:36rem;" required></label></p>
4604<p><label>Resolves by (RFC 3339 or empty for open-ended) <input name="resolves_by" value="{rb_safe}" placeholder="2027-06-30T00:00:00Z" style="width:24rem;"></label></p>
4605<p><label>Resolution criterion <input name="resolution_criterion" value="{rc_safe}" placeholder="We will know this resolved when..." style="width:36rem;" required></label></p>
4606<p><label>Expected outcome
4607<select name="expected_outcome">
4608<option value="affirmed"{sel_aff}>affirmed</option>
4609<option value="falsified"{sel_fal}>falsified</option>
4610</select>
4611</label></p>
4612<p><label>Made by <input name="made_by" value="{made_by_safe}" list="vela-actors" required></label></p>
4613<p><label>Prior belief (0..1) <input name="confidence" type="number" step="0.01" min="0" max="1" value="{conf:.2}" required></label></p>
4614<p><label>Conditions <input name="conditions_text" value="{cond_safe}" placeholder="When this prediction applies" style="width:36rem;"></label></p>
4615<p style="color:var(--ink-3);font-size:0.86rem;">Submission lands as a signed canonical `prediction.deposited` event. Brier scoring runs at resolution time; calibration is part of the proof.</p>
4616<p><button type="submit">Apply</button> <a href="/review/inbox" style="margin-left:1rem;color:var(--ink-3);">Cancel</a></p>
4617</form></div>"#,
4618 datalist = actor_datalist(&project),
4619 error_html = error_html,
4620 fid = escape_html(&f.id),
4621 fid_safe = escape_html(&f.id),
4622 assertion = assertion,
4623 claim_safe = escape_html(&claim_text),
4624 rb_safe = escape_html(&resolves_by),
4625 rc_safe = escape_html(&resolution_criterion),
4626 made_by_safe = escape_html(&made_by),
4627 conf = confidence,
4628 cond_safe = escape_html(&conditions_text),
4629 sel_aff = if expected_outcome == "affirmed" {
4630 " selected"
4631 } else {
4632 ""
4633 },
4634 sel_fal = if expected_outcome == "falsified" {
4635 " selected"
4636 } else {
4637 ""
4638 },
4639 );
4640 let html = shell(
4641 "review",
4642 "Add prediction · Vela Workbench",
4643 "Workbench",
4644 "Add prediction",
4645 &body,
4646 );
4647 Html(html).into_response()
4648}
4649
4650async fn post_review_prediction_add(
4651 State(state): State<AppState>,
4652 Form(form): Form<PredictionAddForm>,
4653) -> Response {
4654 use crate::bundle::{Conditions, ExpectedOutcome, Prediction};
4655 use chrono::Utc;
4656
4657 let lower = form.conditions_text.to_lowercase();
4658 let conditions = Conditions {
4659 text: form.conditions_text.clone(),
4660 species_verified: Vec::new(),
4661 species_unverified: Vec::new(),
4662 in_vitro: lower.contains("in vitro"),
4663 in_vivo: lower.contains("in vivo"),
4664 human_data: lower.contains("human"),
4665 clinical_trial: lower.contains("clinical trial") || lower.contains("phase"),
4666 concentration_range: None,
4667 duration: None,
4668 age_group: None,
4669 cell_type: None,
4670 };
4671 let expected = match form.expected_outcome.as_str() {
4672 "affirmed" => ExpectedOutcome::Affirmed,
4673 "falsified" => ExpectedOutcome::Falsified,
4674 _ => ExpectedOutcome::Affirmed,
4675 };
4676 let resolves_by = if form.resolves_by.trim().is_empty() {
4677 None
4678 } else {
4679 Some(form.resolves_by.trim().to_string())
4680 };
4681 let predicted_at = Utc::now().to_rfc3339();
4682 let pred = Prediction::new(
4683 form.claim_text.clone(),
4684 vec![form.finding_id.clone()],
4685 Some(predicted_at),
4686 resolves_by,
4687 form.resolution_criterion.clone(),
4688 expected,
4689 form.made_by.clone(),
4690 form.confidence,
4691 conditions,
4692 );
4693 match state::deposit_prediction(
4694 &state.repo_path,
4695 pred,
4696 &form.made_by,
4697 "Prediction deposit via local Workbench",
4698 ) {
4699 Ok(_) => Redirect::to("/review/inbox").into_response(),
4700 Err(e) => {
4701 let token = store_form_state(
4702 &state,
4703 FormState::PredictionAdd {
4704 finding_id: form.finding_id.clone(),
4705 claim_text: form.claim_text.clone(),
4706 resolves_by: form.resolves_by.clone(),
4707 resolution_criterion: form.resolution_criterion.clone(),
4708 expected_outcome: form.expected_outcome.clone(),
4709 made_by: form.made_by.clone(),
4710 confidence: form.confidence,
4711 conditions_text: form.conditions_text.clone(),
4712 error: e,
4713 },
4714 );
4715 let url = format!(
4716 "/review/prediction-add/{fid}?error={tok}",
4717 fid = urlencode_path(&form.finding_id),
4718 tok = token,
4719 );
4720 Redirect::to(&url).into_response()
4721 }
4722 }
4723}
4724
4725fn truncate(s: &str, n: usize) -> String {
4726 if s.chars().count() <= n {
4727 return s.to_string();
4728 }
4729 let mut out: String = s.chars().take(n).collect();
4730 out.push('…');
4731 out
4732}
4733
4734fn lookup_cached_abstract(repo_path: &Path, finding: &FindingBundle) -> Option<String> {
4735 use sha2::{Digest, Sha256};
4736 let candidates = [
4738 finding.provenance.doi.as_ref().map(|d| format!("doi:{d}")),
4739 finding
4740 .provenance
4741 .pmid
4742 .as_ref()
4743 .map(|p| format!("pmid:{p}")),
4744 ];
4745 for opt in candidates.iter().flatten() {
4746 let hash = format!("{:x}", Sha256::digest(opt.as_bytes()));
4747 let p = repo_path
4748 .join("sources")
4749 .join("cache")
4750 .join(format!("{hash}.json"));
4751 if !p.is_file() {
4752 continue;
4753 }
4754 if let Ok(body) = std::fs::read_to_string(&p)
4755 && let Ok(value) = serde_json::from_str::<serde_json::Value>(&body)
4756 && let Some(abstract_text) = value.get("abstract").and_then(|v| v.as_str())
4757 && !abstract_text.is_empty()
4758 {
4759 return Some(abstract_text.to_string());
4760 }
4761 }
4762 None
4763}