#![allow(clippy::too_many_lines)]
use std::collections::{BTreeMap, HashMap};
use std::net::SocketAddr;
use std::path::{Path, PathBuf};
use std::sync::{Arc, Mutex};
use std::time::Instant;
use axum::{
Json, Router,
extract::{Form, Path as AxumPath, Query, State},
http::StatusCode,
response::{Html, IntoResponse, Redirect, Response},
routing::{get, post},
};
use rand::RngCore;
use serde::Deserialize;
use tower_http::cors::CorsLayer;
use crate::bridge::{Bridge, BridgeStatus};
use crate::bundle::{FindingBundle, Replication};
use crate::causal_reasoning::{Identifiability, audit_frontier, summarize_audit};
use crate::project::Project;
use crate::proposals::{self, StateProposal};
use crate::repo;
use crate::state::{self, ReviseOptions};
const TOKENS_CSS: &str = include_str!("../embedded/web/styles/tokens.css");
const WORKBENCH_CSS: &str = include_str!("../embedded/web/styles/workbench.css");
const FAVICON_SVG: &str = include_str!("../embedded/assets/brand/favicon.svg");
const WB_VERSION: &str = "0.55.0";
#[derive(Clone)]
struct AppState {
repo_path: Arc<PathBuf>,
form_cache: Arc<Mutex<HashMap<String, FormCacheEntry>>>,
}
const FORM_CACHE_TTL: std::time::Duration = std::time::Duration::from_secs(300);
#[derive(Clone)]
struct FormCacheEntry {
inserted_at: Instant,
state: FormState,
}
#[allow(dead_code)]
#[derive(Clone)]
enum FormState {
LocatorRepair {
atom_id: String,
locator: String,
reviewer: String,
reason: String,
error: String,
},
SpanRepair {
finding_id: String,
section: String,
text: String,
reviewer: String,
reason: String,
error: String,
},
EntityResolve {
finding_id: String,
entity_name: String,
source: String,
id: String,
confidence: f64,
matched_name: Option<String>,
resolution_method: String,
reviewer: String,
reason: String,
error: String,
},
Promote {
finding_id: String,
status: String,
reviewer: String,
reason: String,
error: String,
},
ConflictResolve {
conflict_event_id: String,
resolution_note: String,
winning_proposal_id: Option<String>,
reviewer: String,
error: String,
},
ReplicationAdd {
finding_id: String,
outcome: String,
attempted_by: String,
conditions_text: String,
source_title: String,
doi: String,
pmid: String,
note: String,
error: String,
},
PredictionAdd {
finding_id: String,
claim_text: String,
resolves_by: String,
resolution_criterion: String,
expected_outcome: String,
made_by: String,
confidence: f64,
conditions_text: String,
error: String,
},
}
fn new_form_token() -> String {
let mut buf = [0u8; 16];
rand::thread_rng().fill_bytes(&mut buf);
buf.iter().map(|b| format!("{b:02x}")).collect()
}
fn store_form_state(state: &AppState, fs: FormState) -> String {
let token = new_form_token();
let mut cache = match state.form_cache.lock() {
Ok(c) => c,
Err(e) => e.into_inner(),
};
let now = Instant::now();
cache.retain(|_, entry| now.duration_since(entry.inserted_at) < FORM_CACHE_TTL);
cache.insert(
token.clone(),
FormCacheEntry {
inserted_at: now,
state: fs,
},
);
token
}
fn take_form_state(state: &AppState, token: &str) -> Option<FormState> {
let mut cache = match state.form_cache.lock() {
Ok(c) => c,
Err(e) => e.into_inner(),
};
let now = Instant::now();
cache.retain(|_, entry| now.duration_since(entry.inserted_at) < FORM_CACHE_TTL);
cache.remove(token).map(|e| e.state)
}
#[derive(Debug, Deserialize, Default)]
struct ErrorTokenQuery {
#[serde(default)]
error: Option<String>,
}
fn render_error_banner(message: &str) -> String {
format!(
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>"#,
msg = escape_html(message)
)
}
pub async fn run(repo_path: PathBuf, port: u16, open_browser: bool) -> Result<(), String> {
if !repo_path.join(".vela").is_dir() {
return Err(format!(
"no .vela/ found at {} — run `vela init` first",
repo_path.display()
));
}
let _ =
repo::load_from_path(&repo_path).map_err(|e| format!("failed to load .vela/ repo: {e}"))?;
let state = AppState {
repo_path: Arc::new(repo_path),
form_cache: Arc::new(Mutex::new(HashMap::new())),
};
let app = Router::new()
.route("/", get(page_dashboard))
.route("/findings", get(page_findings))
.route("/findings/{vf_id}", get(page_finding_detail))
.route("/proposals", get(page_proposals))
.route("/proposals/{vpr_id}/preview", get(page_proposal_preview))
.route("/proposals/{vpr_id}/accept", post(post_proposal_accept))
.route("/proposals/{vpr_id}/reject", post(post_proposal_reject))
.route("/proposals/{vpr_id}/revision", post(post_proposal_revision))
.route("/artifact-packets", get(page_artifact_packets))
.route("/audit", get(page_audit))
.route("/bridges", get(page_bridges))
.route("/bridges/{vbr_id}/confirm", post(post_bridge_confirm))
.route("/bridges/{vbr_id}/refute", post(post_bridge_refute))
.route("/negative-results", get(page_negative_results))
.route("/trajectories", get(page_trajectories))
.route("/tiers", get(page_tiers))
.route("/constellation", get(page_constellation))
.route(
"/api/propagate/{vf_id}",
post(post_api_propagate_confidence),
)
.route("/replay/{vf_id}", get(page_replay))
.route("/review/inbox", get(page_review_inbox))
.route(
"/review/locator-repair/{atom_id}",
get(page_review_locator_repair),
)
.route("/review/locator-repair", post(post_review_locator_repair))
.route(
"/review/span-repair/{finding_id}",
get(page_review_span_repair),
)
.route("/review/span-repair", post(post_review_span_repair))
.route(
"/review/entity-resolve/{finding_id}",
get(page_review_entity_resolve),
)
.route("/review/entity-resolve", post(post_review_entity_resolve))
.route("/review/promote/{finding_id}", get(page_review_promote))
.route("/review/promote", post(post_review_promote))
.route(
"/review/conflict-resolve/{conflict_event_id}",
get(page_review_conflict_resolve),
)
.route(
"/review/conflict-resolve",
post(post_review_conflict_resolve),
)
.route(
"/review/replication-add/{finding_id}",
get(page_review_replication_add),
)
.route("/review/replication-add", post(post_review_replication_add))
.route(
"/review/prediction-add/{finding_id}",
get(page_review_prediction_add),
)
.route("/review/prediction-add", post(post_review_prediction_add))
.route("/static/tokens.css", get(static_tokens_css))
.route("/static/workbench.css", get(static_workbench_css))
.route("/static/favicon.svg", get(static_favicon_svg))
.route("/healthz", get(healthz))
.layer(CorsLayer::permissive())
.with_state(state);
let addr: SocketAddr = ([127, 0, 0, 1], port).into();
let listener = tokio::net::TcpListener::bind(&addr)
.await
.map_err(|e| format!("failed to bind {addr}: {e}"))?;
let actual_addr = listener.local_addr().unwrap_or(addr);
let url = format!("http://{actual_addr}/");
println!("vela workbench listening on {url}");
if open_browser && let Err(e) = open_browser_at(&url) {
eprintln!("(could not auto-open browser: {e})");
}
println!("Ctrl-C to stop.");
axum::serve(listener, app)
.await
.map_err(|e| format!("axum serve: {e}"))
}
fn open_browser_at(url: &str) -> Result<(), String> {
#[cfg(target_os = "macos")]
let cmd = "open";
#[cfg(target_os = "linux")]
let cmd = "xdg-open";
#[cfg(target_os = "windows")]
let cmd = "explorer";
std::process::Command::new(cmd)
.arg(url)
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.spawn()
.map(|_| ())
.map_err(|e| format!("{cmd}: {e}"))
}
fn escape_html(s: &str) -> String {
s.replace('&', "&")
.replace('<', "<")
.replace('>', ">")
.replace('"', """)
}
fn urlencode_path(s: &str) -> String {
let mut out = String::with_capacity(s.len());
for b in s.bytes() {
match b {
b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' | b':' => {
out.push(b as char)
}
_ => out.push_str(&format!("%{b:02X}")),
}
}
out
}
fn shell(active: &str, title: &str, eyebrow: &str, page_title: &str, body: &str) -> String {
let nav = |id: &str, href: &str, label: &str| -> String {
let on = if id == active {
" wb-rim__link--on"
} else {
""
};
format!(r#"<a class="wb-rim__link{on}" href="{href}">{label}</a>"#)
};
let constellation_nav = [
nav("tiers", "/tiers", "08 · Tiers"),
nav("bridges", "/bridges", "09 · Bridges"),
nav("constellation", "/constellation", "10 · Constellation"),
]
.join("");
let rim = format!(
r#"<aside class="wb-rim">
<div class="wb-rim__mark">
<a href="/" aria-label="Vela">
<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>
</a>
</div>
<nav class="wb-rim__nav" aria-label="Workbench">
{l1}
{l2}
{l3}
{l4}
{l5}
{l6}
{l7}
{l8}
</nav>
<div class="wb-rim__index">v{ver}</div>
</aside>"#,
l1 = nav("dashboard", "/", "01 · Dashboard"),
l2 = nav("findings", "/findings", "02 · Findings"),
l3 = nav("proposals", "/proposals", "03 · Proposals"),
l4 = nav("packets", "/artifact-packets", "04 · Packets"),
l5 = nav("nulls", "/negative-results", "05 · Nulls"),
l6 = nav("trajectories", "/trajectories", "06 · Trajectories"),
l7 = nav("audit", "/audit", "07 · Audit"),
l8 = constellation_nav,
ver = WB_VERSION,
);
format!(
r#"<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>{title_safe}</title>
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg">
<link rel="stylesheet" href="/static/tokens.css">
<link rel="stylesheet" href="/static/workbench.css">
<style>
body {{ margin: 0; font-family: var(--font-text, system-ui, sans-serif); color: var(--ink-1, #1a1a1a); background: var(--bg-1, #fafaf6); }}
.wb {{ display: grid; grid-template-columns: 200px 1fr; min-height: 100vh; }}
.wb-rim {{ background: var(--bg-2, #f5f2ec); padding: 1rem 0.75rem; border-right: 1px solid var(--rule-2, #d8d4cc); }}
.wb-rim__mark {{ margin-bottom: 1.5rem; }}
.wb-rim__nav {{ display: flex; flex-direction: column; gap: 0.4rem; }}
.wb-rim__link {{ font-size: 0.86rem; color: var(--ink-2, #6b665d); text-decoration: none; padding: 0.3rem 0.5rem; border-radius: 2px; }}
.wb-rim__link--on {{ color: var(--ink-1, #1a1a1a); background: var(--bg-3, #ebe6dd); font-weight: 600; }}
.wb-rim__index {{ margin-top: 2rem; color: var(--ink-3, #a09a8d); font-size: 0.74rem; font-family: ui-monospace, Menlo, monospace; }}
.wb-content {{ padding: 1.5rem 2rem; max-width: 920px; }}
.wb-eyebrow {{ font-size: 0.78rem; text-transform: uppercase; letter-spacing: 0.08em; color: var(--ink-2, #6b665d); margin-bottom: 0.4rem; }}
.wb-title {{ font-size: 1.6rem; margin: 0 0 1rem 0; line-height: 1.2; }}
.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); }}
.wb-stat__num {{ font-family: ui-monospace, Menlo, monospace; font-size: 1.3rem; font-weight: 600; }}
.wb-stat__label {{ font-size: 0.74rem; text-transform: uppercase; letter-spacing: 0.06em; color: var(--ink-2, #6b665d); }}
.wb-card {{ border: 1px solid var(--rule-2, #d8d4cc); padding: 0.85rem 1rem; margin: 0 0 0.85rem 0; }}
.wb-card h3 {{ margin: 0 0 0.4rem 0; font-size: 1rem; }}
.wb-card p {{ margin: 0.2rem 0; font-size: 0.92rem; line-height: 1.55; }}
.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; }}
.wb-chip--ok {{ background: #d6e4d3; color: #2f5d3a; }}
.wb-chip--warn {{ background: #efe2c0; color: #8a6d1f; }}
.wb-chip--lost {{ background: #efd1cf; color: #872c2c; }}
.wb-table {{ width: 100%; border-collapse: collapse; font-size: 0.92rem; }}
.wb-table th, .wb-table td {{ text-align: left; padding: 0.4rem 0.6rem; border-bottom: 1px solid var(--rule-2, #d8d4cc); }}
.wb-table th {{ font-size: 0.74rem; text-transform: uppercase; letter-spacing: 0.06em; color: var(--ink-2, #6b665d); }}
.wb-actions form {{ display: inline-block; margin-right: 0.4em; }}
.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; }}
.wb-actions button:hover {{ background: var(--bg-3, #ebe6dd); }}
code {{ background: var(--bg-3, #ebe6dd); padding: 0.05em 0.3em; border-radius: 2px; font-size: 0.88em; }}
a {{ color: var(--ink-1, #1a1a1a); }}
</style>
</head>
<body>
<div class="wb">
{rim}
<main class="wb-content">
<div class="wb-eyebrow">{eyebrow}</div>
<h1 class="wb-title">{page_title}</h1>
{body}
</main>
</div>
</body>
</html>
"#,
title_safe = escape_html(title),
)
}
fn frontier_label(p: &Project) -> String {
p.project.name.clone()
}
async fn page_dashboard(State(state): State<AppState>) -> Response {
let repo_path = state.repo_path.clone();
let project = match repo::load_from_path(&repo_path) {
Ok(p) => p,
Err(e) => return error_page("dashboard", "Could not load frontier", &e),
};
let label = frontier_label(&project);
let mut pending = 0usize;
let mut by_kind: BTreeMap<String, usize> = BTreeMap::new();
for p in &project.proposals {
if p.status == "pending_review" {
pending += 1;
*by_kind.entry(p.kind.clone()).or_insert(0) += 1;
}
}
let audit = audit_frontier(&project);
let audit_summary = summarize_audit(&audit);
let bridges = list_bridges(&repo_path);
let bridge_total = bridges.len();
let bridge_confirmed = bridges
.iter()
.filter(|b| b.status == BridgeStatus::Confirmed)
.count();
let bridge_derived = bridges
.iter()
.filter(|b| b.status == BridgeStatus::Derived)
.count();
let mut targets_with_success = std::collections::HashSet::new();
let mut failed_replications = 0usize;
for r in &project.replications {
if r.outcome == "replicated" {
targets_with_success.insert(r.target_finding.clone());
} else if r.outcome == "failed" {
failed_replications += 1;
}
}
let stats_html = format!(
r#"<div class="wb-stats">
<div><div class="wb-stat__num">{}</div><div class="wb-stat__label">findings</div></div>
<div><div class="wb-stat__num">{}</div><div class="wb-stat__label">nulls</div></div>
<div><div class="wb-stat__num">{}</div><div class="wb-stat__label">trajectories</div></div>
<div><div class="wb-stat__num">{}</div><div class="wb-stat__label">events</div></div>
</div>
<div class="wb-stats" style="margin-top:0.6rem;">
<div><div class="wb-stat__num">{}</div><div class="wb-stat__label">pending</div></div>
<div><div class="wb-stat__num">{}</div><div class="wb-stat__label">bridges</div></div>
<div><div class="wb-stat__num">{}</div><div class="wb-stat__label">restricted</div></div>
<div><div class="wb-stat__num">{}</div><div class="wb-stat__label">classified</div></div>
</div>"#,
project.findings.len(),
project.negative_results.len(),
project.trajectories.len(),
project.events.len(),
pending,
bridge_total,
project
.findings
.iter()
.filter(|f| matches!(f.access_tier, crate::access_tier::AccessTier::Restricted))
.count()
+ project
.negative_results
.iter()
.filter(|n| matches!(n.access_tier, crate::access_tier::AccessTier::Restricted))
.count()
+ project
.trajectories
.iter()
.filter(|t| matches!(t.access_tier, crate::access_tier::AccessTier::Restricted))
.count(),
project
.findings
.iter()
.filter(|f| matches!(f.access_tier, crate::access_tier::AccessTier::Classified))
.count()
+ project
.negative_results
.iter()
.filter(|n| matches!(n.access_tier, crate::access_tier::AccessTier::Classified))
.count()
+ project
.trajectories
.iter()
.filter(|t| matches!(t.access_tier, crate::access_tier::AccessTier::Classified))
.count(),
);
let mut cards = String::new();
if pending > 0 {
let parts: Vec<String> = by_kind
.iter()
.map(|(k, n)| format!("<code>{n}</code> {}", escape_html(k)))
.collect();
cards.push_str(&format!(
r#"<div class="wb-card">
<h3><span class="wb-chip wb-chip--warn">inbox</span>{} pending proposals</h3>
<p>{}</p>
<p><a href="/audit">Open audit →</a></p>
</div>"#,
pending,
parts.join(" · ")
));
}
if audit_summary.underidentified > 0 || audit_summary.conditional > 0 {
let chip_kind = if audit_summary.underidentified > 0 {
"lost"
} else {
"warn"
};
cards.push_str(&format!(
r#"<div class="wb-card">
<h3><span class="wb-chip wb-chip--{chip}">audit</span>identifiability</h3>
<p><strong>{}</strong> underidentified · <strong>{}</strong> conditional · <strong>{}</strong> identified</p>
<p><a href="/audit">Open audit →</a></p>
</div>"#,
audit_summary.underidentified,
audit_summary.conditional,
audit_summary.identified,
chip = chip_kind,
));
}
if bridge_total > 0 {
cards.push_str(&format!(
r#"<div class="wb-card">
<h3><span class="wb-chip wb-chip--ok">bridges</span>cross-frontier composition</h3>
<p><strong>{bridge_total}</strong> total · <strong>{bridge_confirmed}</strong> confirmed · <strong>{bridge_derived}</strong> awaiting review</p>
<p><a href="/bridges">Open bridges →</a></p>
</div>"#
));
}
if !project.replications.is_empty() {
cards.push_str(&format!(
r#"<div class="wb-card">
<h3><span class="wb-chip wb-chip--ok">replications</span>empirical bedrock</h3>
<p><strong>{}</strong> records · <strong>{}</strong> findings replicated · <strong>{}</strong> failed</p>
</div>"#,
project.replications.len(),
targets_with_success.len(),
failed_replications
));
}
let body = format!("{stats_html}{cards}");
Html(shell(
"dashboard",
&format!("Vela workbench · {label}"),
"Workbench",
&escape_html(&label),
&body,
))
.into_response()
}
async fn page_findings(State(state): State<AppState>) -> Response {
let project = match repo::load_from_path(&state.repo_path) {
Ok(p) => p,
Err(e) => return error_page("findings", "Could not load frontier", &e),
};
let mut rows = String::new();
for f in project.findings.iter().take(500) {
let conf_pct = (f.confidence.score * 100.0).round() as i64;
let claim = f.assertion.causal_claim.map_or("—", |c| match c {
crate::bundle::CausalClaim::Correlation => "correlation",
crate::bundle::CausalClaim::Mediation => "mediation",
crate::bundle::CausalClaim::Intervention => "intervention",
});
let assertion_short: String = f.assertion.text.chars().take(110).collect();
rows.push_str(&format!(
r#"<tr>
<td><a href="/findings/{vf}"><code>{vf_short}</code></a></td>
<td>{conf}%</td>
<td>{claim}</td>
<td>{text}</td>
</tr>"#,
vf = escape_html(&f.id),
vf_short = escape_html(&f.id),
conf = conf_pct,
claim = claim,
text = escape_html(&assertion_short),
));
}
let body = format!(
r#"<table class="wb-table">
<thead>
<tr><th>vf_id</th><th>conf</th><th>claim</th><th>assertion</th></tr>
</thead>
<tbody>
{rows}
</tbody>
</table>"#
);
Html(shell(
"findings",
"Findings",
"Workbench",
&format!("{} findings", project.findings.len()),
&body,
))
.into_response()
}
async fn page_finding_detail(
AxumPath(vf_id): AxumPath<String>,
State(state): State<AppState>,
) -> Response {
let project = match repo::load_from_path(&state.repo_path) {
Ok(p) => p,
Err(e) => return error_page("findings", "Could not load frontier", &e),
};
let Some(f) = project.findings.iter().find(|f| f.id == vf_id) else {
return error_page(
"findings",
"Finding not found",
&format!("no finding with id {vf_id}"),
);
};
let conf_pct = (f.confidence.score * 100.0).round() as i64;
let mut links_html = String::new();
if !f.links.is_empty() {
links_html.push_str(r#"<table class="wb-table"><thead><tr><th>type</th><th>target</th><th>mechanism</th></tr></thead><tbody>"#);
for l in &f.links {
let mech = l.mechanism.map_or("—".to_string(), |m| {
use crate::bundle::Mechanism;
match m {
Mechanism::Linear { sign, slope } => {
format!("linear {sign:?} slope {slope:.2}")
}
Mechanism::Monotonic { sign } => format!("monotonic {sign:?}"),
Mechanism::Threshold { sign, threshold } => {
format!("threshold {sign:?} {threshold:.2}")
}
Mechanism::Saturating { sign, half_max } => {
format!("saturating {sign:?} half_max {half_max:.2}")
}
Mechanism::Unknown => "unknown".into(),
}
});
links_html.push_str(&format!(
"<tr><td><code>{}</code></td><td><code>{}</code></td><td>{}</td></tr>",
escape_html(&l.link_type),
escape_html(&l.target),
escape_html(&mech)
));
}
links_html.push_str("</tbody></table>");
}
let assertion = escape_html(&f.assertion.text);
let source_block = {
let mut parts: Vec<String> = Vec::new();
if let Some(doi) = f.provenance.doi.as_deref().filter(|s| !s.is_empty()) {
parts.push(format!(
"<a href=\"https://doi.org/{doi}\" target=\"_blank\" rel=\"noopener\"><code>doi:{doi}</code></a>",
doi = escape_html(doi)
));
}
if let Some(pmid) = f.provenance.pmid.as_deref().filter(|s| !s.is_empty()) {
parts.push(format!(
"<a href=\"https://pubmed.ncbi.nlm.nih.gov/{pmid}/\" target=\"_blank\" rel=\"noopener\"><code>pmid:{pmid}</code></a>",
pmid = escape_html(pmid)
));
}
if let Some(y) = f.provenance.year {
parts.push(format!("{y}"));
}
if let Some(j) = f.provenance.journal.as_deref().filter(|s| !s.is_empty()) {
parts.push(escape_html(j));
}
if parts.is_empty() {
"<span style=\"color:var(--ink-3);\">no source metadata</span>".to_string()
} else {
parts.join(" · ")
}
};
let mut spans_block = String::new();
if f.evidence.evidence_spans.is_empty() {
spans_block.push_str(
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>"#,
);
} else {
for s in &f.evidence.evidence_spans {
let section = s
.get("section")
.and_then(|v| v.as_str())
.unwrap_or("(unsectioned)");
let text = s.get("text").and_then(|v| v.as_str()).unwrap_or("");
if text.is_empty() {
continue;
}
spans_block.push_str(&format!(
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>"#,
section = escape_html(section),
text = escape_html(text),
));
}
}
let review_state_label = match &f.flags.review_state {
Some(crate::bundle::ReviewState::Accepted) => "<code>accepted</code>",
Some(crate::bundle::ReviewState::Contested) => "<code>contested</code>",
Some(crate::bundle::ReviewState::NeedsRevision) => "<code>needs_revision</code>",
Some(crate::bundle::ReviewState::Rejected) => "<code>rejected</code>",
None => "<span style=\"color:var(--ink-3);\">(unset)</span>",
};
let mut events_for_finding: Vec<&crate::events::StateEvent> = project
.events
.iter()
.filter(|e| e.target.id == f.id)
.collect();
events_for_finding.sort_by(|a, b| a.timestamp.cmp(&b.timestamp));
let mut history_block = String::new();
if events_for_finding.is_empty() {
history_block.push_str(
r#"<p style="color:var(--ink-3);font-size:0.86rem;">No canonical events recorded against this finding yet.</p>"#,
);
} else {
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>"#);
for ev in &events_for_finding {
let when = if ev.timestamp.len() >= 10 {
&ev.timestamp[..10]
} else {
ev.timestamp.as_str()
};
history_block.push_str(&format!(
r#"<tr><td><code>{when}</code></td><td><code>{kind}</code></td><td><code>{actor}</code></td><td>{reason}</td></tr>"#,
when = escape_html(when),
kind = escape_html(&ev.kind),
actor = escape_html(&ev.actor.id),
reason = escape_html(&ev.reason),
));
}
history_block.push_str("</tbody></table>");
}
let caveats_block = if f.flags.review_state.is_none() && f.evidence.evidence_spans.is_empty() {
r#"<p style="color:var(--ink-3);font-size:0.86rem;">Draft finding with no evidence_spans yet.</p>"#.to_string()
} else {
String::new()
};
let body = format!(
r#"<div class="wb-stats">
<div><div class="wb-stat__num">{conf_pct}%</div><div class="wb-stat__label">confidence</div></div>
<div><div class="wb-stat__num">{n_links}</div><div class="wb-stat__label">links</div></div>
<div><div class="wb-stat__num">{n_events}</div><div class="wb-stat__label">events</div></div>
<div><div class="wb-stat__num">{atype}</div><div class="wb-stat__label">type</div></div>
</div>
<div class="wb-card">
<h3>Assertion</h3>
<p>{assertion}</p>
<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>
{caveats_block}
</div>
<div class="wb-card">
<h3>Evidence</h3>
{spans_block}
</div>
<div class="wb-card">
<h3>Links</h3>
{links_html}
</div>
<div class="wb-card">
<h3>History</h3>
{history_block}
</div>"#,
n_links = f.links.len(),
n_events = events_for_finding.len(),
atype = escape_html(&f.assertion.assertion_type),
ver = f.version,
);
Html(shell(
"findings",
&format!("{} · {}", vf_id, project.project.name),
"Finding",
&vf_id,
&body,
))
.into_response()
}
async fn page_proposals(State(state): State<AppState>) -> Response {
let project = match repo::load_from_path(&state.repo_path) {
Ok(p) => p,
Err(e) => return error_page("proposals", "Could not load frontier", &e),
};
let mut proposals = project.proposals.clone();
proposals.sort_by(|a, b| {
status_rank(&a.status)
.cmp(&status_rank(&b.status))
.then(a.created_at.cmp(&b.created_at))
.then(a.id.cmp(&b.id))
});
let pending = proposals
.iter()
.filter(|proposal| proposal.status == "pending_review")
.count();
let needs_revision = proposals
.iter()
.filter(|proposal| proposal.status == "needs_revision")
.count();
let applied = proposals
.iter()
.filter(|proposal| proposal.status == "applied")
.count();
let rows = proposals
.iter()
.take(300)
.map(render_proposal_row)
.collect::<String>();
let body = format!(
r#"<div class="wb-stats">
<div><div class="wb-stat__num">{pending}</div><div class="wb-stat__label">pending</div></div>
<div><div class="wb-stat__num">{needs_revision}</div><div class="wb-stat__label">revision</div></div>
<div><div class="wb-stat__num">{applied}</div><div class="wb-stat__label">applied</div></div>
<div><div class="wb-stat__num">{total}</div><div class="wb-stat__label">total</div></div>
</div>
<div class="wb-card">
<h3>Proposal inbox</h3>
<p>External runtime output is source material. Validate the packet, preview the state diff, then accept, reject, or request revision.</p>
<pre><code>vela bridge-kit validate packet.json --json
vela proposals preview FRONTIER vpr_... --json</code></pre>
</div>
<table class="wb-table">
<thead>
<tr><th>status</th><th>proposal</th><th>target</th><th>packet/source</th><th>actions</th></tr>
</thead>
<tbody>
{rows}
</tbody>
</table>"#,
total = proposals.len(),
);
Html(shell(
"proposals",
&format!("Proposal inbox · {}", project.project.name),
"Workbench",
"Proposal inbox",
&body,
))
.into_response()
}
async fn page_proposal_preview(
AxumPath(vpr_id): AxumPath<String>,
State(state): State<AppState>,
) -> Response {
let project = match repo::load_from_path(&state.repo_path) {
Ok(p) => p,
Err(e) => return error_page("proposals", "Could not load frontier", &e),
};
let Some(proposal) = project
.proposals
.iter()
.find(|proposal| proposal.id == vpr_id)
else {
return error_page("proposals", "Proposal not found", &vpr_id);
};
let preview = if matches!(
proposal.status.as_str(),
"pending_review" | "accepted" | "applied"
) {
match proposals::preview_at_path(&state.repo_path, &vpr_id, "reviewer:workbench") {
Ok(preview) => Some(preview),
Err(e) => return error_page("proposals", "Could not preview proposal", &e),
}
} else {
None
};
let findings_delta = preview.as_ref().map_or(0, |preview| preview.findings_delta);
let artifacts_delta = preview
.as_ref()
.map_or(0, |preview| preview.artifacts_delta);
let events_delta = preview.as_ref().map_or(0, |preview| preview.events_delta);
let event_id = preview
.as_ref()
.map(|preview| preview.applied_event_id.clone())
.or_else(|| proposal.applied_event_id.clone())
.unwrap_or_else(|| "event not applied".to_string());
let actions = if proposal.status == "pending_review" || proposal.status == "needs_revision" {
format!(
r#"<div class="wb-actions">
<form method="post" action="/proposals/{id}/accept">
<input type="hidden" name="reviewer" value="reviewer:workbench">
<input type="hidden" name="reason" value="Accepted from local workbench review.">
<button type="submit">Accept</button>
</form>
<form method="post" action="/proposals/{id}/revision">
<input type="hidden" name="reviewer" value="reviewer:workbench">
<input type="hidden" name="reason" value="Needs clearer artifact or evidence scope.">
<button type="submit">Request revision</button>
</form>
<form method="post" action="/proposals/{id}/reject">
<input type="hidden" name="reviewer" value="reviewer:workbench">
<input type="hidden" name="reason" value="Rejected from local workbench review.">
<button type="submit">Reject</button>
</form>
</div>"#,
id = escape_html(&proposal.id)
)
} else {
format!(
r#"<div class="wb-card"><p>This proposal is <code>{}</code>. It is shown as review history.</p></div>"#,
escape_html(&proposal.status)
)
};
let diff_text = if proposal.status == "applied" {
format!(
"This proposal already emitted <code>{}</code>. The preview reports the recorded event and leaves the frontier unchanged.",
escape_html(&event_id)
)
} else if proposal.status == "rejected" {
"This proposal was rejected. It remains visible as review history and is not applied to the frontier.".to_string()
} else {
format!(
"Accepting this proposal would emit <code>{}</code> and mutate the in-memory frontier by the deltas above. This preview has not written to disk.",
escape_html(&event_id)
)
};
let body = format!(
r#"<div class="wb-card">
<h3><span class="wb-chip wb-chip--warn">preview</span>{id}</h3>
<p>{reason}</p>
<p><code>{kind}</code> targets <code>{target_type}:{target_id}</code></p>
</div>
<div class="wb-stats">
<div><div class="wb-stat__num">{findings_delta:+}</div><div class="wb-stat__label">findings</div></div>
<div><div class="wb-stat__num">{artifacts_delta:+}</div><div class="wb-stat__label">artifacts</div></div>
<div><div class="wb-stat__num">{events_delta:+}</div><div class="wb-stat__label">events</div></div>
<div><div class="wb-stat__num">stale</div><div class="wb-stat__label">proof after accept</div></div>
</div>
<div class="wb-card">
<h3>Reviewer diff</h3>
<p>{diff_text}</p>
<p>External confidence, comments, and votes remain provenance. Only this review action changes canonical frontier state.</p>
</div>
<div class="wb-card">
<h3>Source packet</h3>
{packet}
</div>
{actions}
<div class="wb-card">
<h3>Proposal payload</h3>
<pre><code>{payload}</code></pre>
</div>"#,
id = escape_html(&proposal.id),
reason = escape_html(&proposal.reason),
kind = escape_html(&proposal.kind),
target_type = escape_html(&proposal.target.r#type),
target_id = escape_html(&proposal.target.id),
findings_delta = findings_delta,
artifacts_delta = artifacts_delta,
events_delta = events_delta,
diff_text = diff_text,
packet = render_packet_reference(proposal),
actions = actions,
payload = escape_html(&pretty_json(&proposal.payload)),
);
Html(shell(
"proposals",
&format!("Proposal preview · {}", project.project.name),
"Proposal",
&proposal.id,
&body,
))
.into_response()
}
async fn page_artifact_packets(State(state): State<AppState>) -> Response {
let project = match repo::load_from_path(&state.repo_path) {
Ok(p) => p,
Err(e) => return error_page("packets", "Could not load frontier", &e),
};
let mut packets: BTreeMap<String, Vec<StateProposal>> = BTreeMap::new();
for proposal in &project.proposals {
if let Some(packet_id) = proposal_packet_id(proposal) {
packets
.entry(packet_id.to_string())
.or_default()
.push(proposal.clone());
}
}
let cards = if packets.is_empty() {
r#"<div class="wb-card"><p>No artifact packet provenance is present in the proposal ledger.</p></div>"#.to_string()
} else {
packets
.iter()
.map(|(packet_id, proposals)| {
let applied = proposals
.iter()
.filter(|proposal| proposal.status == "applied")
.count();
let pending = proposals
.iter()
.filter(|proposal| proposal.status == "pending_review")
.count();
let proposal_links = proposals
.iter()
.map(|proposal| {
format!(
r#"<p><a href="/proposals/{id}/preview"><code>{id}</code></a> · {kind} · {status}</p>"#,
id = escape_html(&proposal.id),
kind = escape_html(&proposal.kind),
status = escape_html(&proposal.status),
)
})
.collect::<String>();
format!(
r#"<div class="wb-card">
<h3><span class="wb-chip wb-chip--ok">packet</span>{packet_id}</h3>
<p><strong>{count}</strong> generated proposals · <strong>{applied}</strong> applied · <strong>{pending}</strong> pending review.</p>
{proposal_links}
</div>"#,
packet_id = escape_html(packet_id),
count = proposals.len(),
)
})
.collect::<String>()
};
let body = format!(
r#"<div class="wb-card">
<h3>Artifact packet ledger</h3>
<p>ScienceClaw-shaped packets are source material. Vela records their artifacts, claims, and open needs as reviewable proposals before state changes.</p>
</div>
{cards}"#
);
Html(shell(
"packets",
&format!("Artifact packets · {}", project.project.name),
"Workbench",
"Artifact packets",
&body,
))
.into_response()
}
fn status_rank(status: &str) -> u8 {
match status {
"pending_review" => 0,
"needs_revision" => 1,
"accepted" => 2,
"applied" => 3,
"rejected" => 4,
_ => 5,
}
}
fn render_proposal_row(proposal: &StateProposal) -> String {
let chip = match proposal.status.as_str() {
"applied" | "accepted" => "ok",
"pending_review" | "needs_revision" => "warn",
"rejected" => "lost",
_ => "warn",
};
let packet = proposal_packet_id(proposal).unwrap_or("");
let source = if packet.is_empty() {
proposal
.source_refs
.first()
.map(|value| escape_html(value))
.unwrap_or_else(|| "source not declared".to_string())
} else {
format!("<code>{}</code>", escape_html(packet))
};
let actions = if matches!(
proposal.status.as_str(),
"pending_review" | "needs_revision"
) {
format!(
r#"<div class="wb-actions">
<a href="/proposals/{id}/preview">Preview</a>
<form method="post" action="/proposals/{id}/accept">
<input type="hidden" name="reviewer" value="reviewer:workbench">
<input type="hidden" name="reason" value="Accepted from local workbench review.">
<button type="submit">Accept</button>
</form>
<form method="post" action="/proposals/{id}/revision">
<input type="hidden" name="reviewer" value="reviewer:workbench">
<input type="hidden" name="reason" value="Needs clearer artifact or evidence scope.">
<button type="submit">Request revision</button>
</form>
<form method="post" action="/proposals/{id}/reject">
<input type="hidden" name="reviewer" value="reviewer:workbench">
<input type="hidden" name="reason" value="Rejected from local workbench review.">
<button type="submit">Reject</button>
</form>
</div>"#,
id = escape_html(&proposal.id),
)
} else {
format!(
r#"<a href="/proposals/{}/preview">Preview</a>"#,
escape_html(&proposal.id)
)
};
format!(
r#"<tr>
<td><span class="wb-chip wb-chip--{chip}">{status}</span></td>
<td><a href="/proposals/{id}/preview"><code>{id}</code></a><br>{reason}</td>
<td><code>{target_type}:{target_id}</code><br><code>{kind}</code></td>
<td>{source}</td>
<td>{actions}</td>
</tr>"#,
status = escape_html(&proposal.status),
id = escape_html(&proposal.id),
reason = escape_html(&proposal.reason),
target_type = escape_html(&proposal.target.r#type),
target_id = escape_html(&proposal.target.id),
kind = escape_html(&proposal.kind),
)
}
fn proposal_packet_id(proposal: &StateProposal) -> Option<&str> {
proposal
.payload
.get("artifact_packet")
.and_then(|packet| packet.get("packet_id"))
.and_then(|value| value.as_str())
.or_else(|| {
proposal
.payload
.get("artifact_packet_id")
.and_then(|value| value.as_str())
})
}
fn render_packet_reference(proposal: &StateProposal) -> String {
let Some(packet) = proposal.payload.get("artifact_packet") else {
return "<p>No artifact packet metadata is attached.</p>".to_string();
};
let packet_id = packet
.get("packet_id")
.and_then(|value| value.as_str())
.unwrap_or("packet id unavailable");
let producer = packet
.get("producer")
.and_then(|producer| producer.get("id"))
.and_then(|value| value.as_str())
.unwrap_or("producer unavailable");
let artifact_ids = packet
.get("external_artifact_ids")
.and_then(|value| value.as_array())
.map(|ids| {
ids.iter()
.filter_map(|value| value.as_str())
.map(|id| format!("<code>{}</code>", escape_html(id)))
.collect::<Vec<_>>()
.join(" ")
})
.unwrap_or_default();
format!(
r#"<p><code>{}</code> from <code>{}</code></p><p>{}</p>"#,
escape_html(packet_id),
escape_html(producer),
artifact_ids
)
}
fn pretty_json(value: &serde_json::Value) -> String {
serde_json::to_string_pretty(value).unwrap_or_else(|_| "{}".to_string())
}
async fn page_audit(State(state): State<AppState>) -> Response {
let project = match repo::load_from_path(&state.repo_path) {
Ok(p) => p,
Err(e) => return error_page("audit", "Could not load frontier", &e),
};
let mut entries = audit_frontier(&project);
let summary = summarize_audit(&entries);
entries.retain(|e| {
matches!(
e.verdict,
Identifiability::Underidentified | Identifiability::Conditional
)
});
let mut rows = String::new();
for e in &entries {
let chip = match e.verdict {
Identifiability::Underidentified => "lost",
Identifiability::Conditional => "warn",
_ => continue,
};
let claim = e
.causal_claim
.map_or("—".to_string(), |c| format!("{c:?}").to_lowercase());
let grade = e
.causal_evidence_grade
.map_or("—".to_string(), |g| format!("{g:?}").to_lowercase());
let text: String = e.assertion_text.chars().take(120).collect();
rows.push_str(&format!(
r#"<tr>
<td><span class="wb-chip wb-chip--{chip}">{verdict}</span></td>
<td><a href="/findings/{vf}"><code>{vf_short}</code></a></td>
<td>{claim} / {grade}</td>
<td>{text}</td>
</tr>"#,
chip = chip,
verdict = match e.verdict {
Identifiability::Underidentified => "underidentified",
Identifiability::Conditional => "conditional",
_ => "—",
},
vf = escape_html(&e.finding_id),
vf_short = escape_html(&e.finding_id),
claim = escape_html(&claim),
grade = escape_html(&grade),
text = escape_html(&text),
));
}
let stats_html = format!(
r#"<div class="wb-stats">
<div><div class="wb-stat__num">{}</div><div class="wb-stat__label">identified</div></div>
<div><div class="wb-stat__num">{}</div><div class="wb-stat__label">conditional</div></div>
<div><div class="wb-stat__num">{}</div><div class="wb-stat__label">underidentified</div></div>
<div><div class="wb-stat__num">{}</div><div class="wb-stat__label">underdetermined</div></div>
</div>"#,
summary.identified, summary.conditional, summary.underidentified, summary.underdetermined,
);
let body = if entries.is_empty() {
format!(
"{stats_html}<div class=\"wb-card\"><p>No reviewer-attention items. Audit clean.</p></div>"
)
} else {
format!(
r#"{stats_html}
<table class="wb-table">
<thead>
<tr><th>verdict</th><th>finding</th><th>claim/grade</th><th>assertion</th></tr>
</thead>
<tbody>
{rows}
</tbody>
</table>"#
)
};
Html(shell(
"audit",
"Causal audit",
"Workbench",
"Identifiability audit",
&body,
))
.into_response()
}
async fn page_bridges(State(state): State<AppState>) -> Response {
let bridges = list_bridges(&state.repo_path);
if bridges.is_empty() {
let body = r#"<div class="wb-card">
<p>No bridges yet. Derive one with:</p>
<p><code>vela bridges derive <frontier_a> <frontier_b></code></p>
</div>"#;
return Html(shell("bridges", "Bridges", "Workbench", "No bridges", body)).into_response();
}
let mut cards = String::new();
for b in &bridges {
let chip = match b.status {
BridgeStatus::Confirmed => "ok",
BridgeStatus::Refuted => "lost",
BridgeStatus::Derived => "warn",
};
let chip_label = match b.status {
BridgeStatus::Confirmed => "confirmed",
BridgeStatus::Refuted => "refuted",
BridgeStatus::Derived => "derived",
};
let mut refs_html = String::new();
for r in b.finding_refs.iter().take(6) {
let txt: String = r.assertion_text.chars().take(110).collect();
refs_html.push_str(&format!(
"<p>· <code>[{}]</code> <code>{}</code> conf {:.2} — {}</p>",
escape_html(&r.frontier),
escape_html(&r.finding_id),
r.confidence,
escape_html(&txt),
));
}
if b.finding_refs.len() > 6 {
refs_html.push_str(&format!("<p>… and {} more</p>", b.finding_refs.len() - 6));
}
let actions_html = match b.status {
BridgeStatus::Derived => format!(
r#"<div class="wb-actions">
<form method="post" action="/bridges/{id}/confirm"><button type="submit">Confirm</button></form>
<form method="post" action="/bridges/{id}/refute"><button type="submit">Refute</button></form>
</div>"#,
id = escape_html(&b.id),
),
BridgeStatus::Confirmed => format!(
r#"<div class="wb-actions">
<form method="post" action="/bridges/{id}/refute"><button type="submit">Mark refuted</button></form>
</div>"#,
id = escape_html(&b.id),
),
BridgeStatus::Refuted => format!(
r#"<div class="wb-actions">
<form method="post" action="/bridges/{id}/confirm"><button type="submit">Re-confirm</button></form>
</div>"#,
id = escape_html(&b.id),
),
};
let tension_html = b.tension.as_deref().map_or(String::new(), |t| {
format!(
r#"<p style="color:#872c2c;font-style:italic;">tension: {}</p>"#,
escape_html(t)
)
});
cards.push_str(&format!(
r#"<div class="wb-card">
<h3><span class="wb-chip wb-chip--{chip}">{chip_label}</span><code>{id}</code> · {entity}</h3>
<p><strong>frontiers:</strong> {frontiers} · <strong>findings:</strong> {n_refs}</p>
{tension_html}
{refs_html}
{actions_html}
</div>"#,
chip = chip,
chip_label = chip_label,
id = escape_html(&b.id),
entity = escape_html(&b.entity_name),
frontiers = escape_html(&b.frontiers.join(" ↔ ")),
n_refs = b.finding_refs.len(),
));
}
let body = cards;
Html(shell(
"bridges",
"Bridges",
"Workbench",
&format!("{} cross-frontier bridge(s)", bridges.len()),
&body,
))
.into_response()
}
async fn page_negative_results(State(state): State<AppState>) -> Response {
let project = match repo::load_from_path(&state.repo_path) {
Ok(p) => p,
Err(e) => return error_page("nulls", "Could not load frontier", &e),
};
if project.negative_results.is_empty() {
let body = r#"<div class="wb-card">
<p>No NegativeResults deposited yet. Add one with:</p>
<p><code>vela negative-result-add <frontier> --kind exploratory \
--reagent <...> --observation <...> --attempts <n> \
--deposited-by <actor> --reason <...> \
--conditions-text <...> --source-title <...></code></p>
<p>Or for a registered-trial null:</p>
<p><code>vela negative-result-add <frontier> --kind registered_trial \
--endpoint <...> --intervention <...> --comparator <...> \
--population <...> --n-enrolled <n> --power <p> \
--ci-lower <l> --ci-upper <u> ...</code></p>
</div>"#;
return Html(shell(
"nulls",
"Negative Results",
"Workbench",
"No NegativeResults",
body,
))
.into_response();
}
let mut trial_count = 0usize;
let mut exploratory_count = 0usize;
let mut informative_count = 0usize;
for nr in &project.negative_results {
match &nr.kind {
crate::bundle::NegativeResultKind::RegisteredTrial { .. } => trial_count += 1,
crate::bundle::NegativeResultKind::Exploratory { .. } => exploratory_count += 1,
}
if nr.is_informative_trial_null() == Some(true) {
informative_count += 1;
}
}
let stats_html = format!(
r#"<div class="wb-stats">
<div><div class="wb-stat__num">{total}</div><div class="wb-stat__label">total</div></div>
<div><div class="wb-stat__num">{trial}</div><div class="wb-stat__label">trial</div></div>
<div><div class="wb-stat__num">{expl}</div><div class="wb-stat__label">exploratory</div></div>
<div><div class="wb-stat__num">{inf}</div><div class="wb-stat__label">informative</div></div>
</div>"#,
total = project.negative_results.len(),
trial = trial_count,
expl = exploratory_count,
inf = informative_count,
);
let mut cards = String::new();
for nr in &project.negative_results {
let (chip_kind, chip_label, kind_body) = match &nr.kind {
crate::bundle::NegativeResultKind::RegisteredTrial {
endpoint,
intervention,
comparator,
population,
n_enrolled,
power,
effect_size_ci,
effect_size_threshold,
registry_id,
} => {
let informative = nr.is_informative_trial_null();
let inf_chip = match informative {
Some(true) => r#"<span class="wb-chip wb-chip--ok">informative</span>"#,
Some(false) => r#"<span class="wb-chip wb-chip--warn">uninformative</span>"#,
None => "",
};
let mcid = effect_size_threshold
.map(|t| format!("MCID ±{t:.3}"))
.unwrap_or_else(|| "no MCID declared".to_string());
let registry = registry_id
.as_deref()
.map(|r| format!(" · <code>{}</code>", escape_html(r)))
.unwrap_or_default();
(
"warn",
"registered_trial",
format!(
"<p>{inf_chip}<strong>{ep}</strong>{reg}</p>\
<p>{int} vs {cmp} · {pop}</p>\
<p>n={n} · power {pw:.2} · CI [{lo:.3}, {hi:.3}] · {mcid}</p>",
ep = escape_html(endpoint),
reg = registry,
int = escape_html(intervention),
cmp = escape_html(comparator),
pop = escape_html(population),
n = n_enrolled,
pw = power,
lo = effect_size_ci.0,
hi = effect_size_ci.1,
),
)
}
crate::bundle::NegativeResultKind::Exploratory {
reagent,
observation,
attempts,
} => (
"warn",
"exploratory",
format!(
"<p><strong>reagent:</strong> {r}</p>\
<p><strong>observation:</strong> {o}</p>\
<p><strong>attempts:</strong> {a}</p>",
r = escape_html(reagent),
o = escape_html(observation),
a = attempts,
),
),
};
let retracted_chip = if nr.retracted {
r#"<span class="wb-chip wb-chip--lost">retracted</span>"#
} else {
""
};
let review_chip = nr
.review_state
.as_ref()
.map(|s| {
let (c, label) = match s {
crate::bundle::ReviewState::Accepted => ("ok", "accepted"),
crate::bundle::ReviewState::Contested => ("warn", "contested"),
crate::bundle::ReviewState::NeedsRevision => ("warn", "needs revision"),
crate::bundle::ReviewState::Rejected => ("lost", "rejected"),
};
format!(r#"<span class="wb-chip wb-chip--{c}">{label}</span>"#)
})
.unwrap_or_default();
let tier_chip = if !matches!(nr.access_tier, crate::access_tier::AccessTier::Public) {
format!(
r#"<span class="wb-chip wb-chip--lost">{}</span>"#,
nr.access_tier.canonical()
)
} else {
String::new()
};
let targets_html = if nr.target_findings.is_empty() {
String::new()
} else {
let links: Vec<String> = nr
.target_findings
.iter()
.map(|t| {
format!(
r#"<a href="/findings/{t}"><code>{t}</code></a>"#,
t = escape_html(t)
)
})
.collect();
format!(
"<p><strong>bears against:</strong> {}</p>",
links.join(" · ")
)
};
let notes_html = if nr.notes.trim().is_empty() {
String::new()
} else {
format!(
"<p style=\"color:var(--ink-2,#6b665d);font-style:italic;\">{}</p>",
escape_html(&nr.notes)
)
};
cards.push_str(&format!(
r#"<div class="wb-card">
<h3><span class="wb-chip wb-chip--{chip_kind}">{chip_label}</span>{retracted_chip}{review_chip}{tier_chip}<code>{id}</code></h3>
{kind_body}
{targets_html}
{notes_html}
<p style="font-size:0.78rem;color:var(--ink-3,#a09a8d);">deposited by {actor} · {created}</p>
</div>"#,
id = escape_html(&nr.id),
actor = escape_html(&nr.deposited_by),
created = escape_html(&nr.created),
));
}
let body = format!("{stats_html}{cards}");
Html(shell(
"nulls",
"Negative Results",
"Workbench",
&format!("{} negative result(s)", project.negative_results.len()),
&body,
))
.into_response()
}
async fn page_trajectories(State(state): State<AppState>) -> Response {
let project = match repo::load_from_path(&state.repo_path) {
Ok(p) => p,
Err(e) => return error_page("trajectories", "Could not load frontier", &e),
};
if project.trajectories.is_empty() {
let body = r#"<div class="wb-card">
<p>No trajectories deposited yet. Open one with:</p>
<p><code>vela trajectory-create <frontier> --deposited-by <actor> \
--reason <...> [--target vf_…]* [--notes <...>]</code></p>
<p>Then append steps:</p>
<p><code>vela trajectory-step <frontier> <vtr_id> \
--kind hypothesis|tried|ruled_out|observed|refined \
--description <...> --actor <id> --reason <...></code></p>
</div>"#;
return Html(shell(
"trajectories",
"Trajectories",
"Workbench",
"No trajectories",
body,
))
.into_response();
}
let total_steps: usize = project.trajectories.iter().map(|t| t.steps.len()).sum();
let stats_html = format!(
r#"<div class="wb-stats">
<div><div class="wb-stat__num">{}</div><div class="wb-stat__label">trajectories</div></div>
<div><div class="wb-stat__num">{}</div><div class="wb-stat__label">total steps</div></div>
<div><div class="wb-stat__num">{}</div><div class="wb-stat__label">retracted</div></div>
<div><div class="wb-stat__num">{}</div><div class="wb-stat__label">reviewed</div></div>
</div>"#,
project.trajectories.len(),
total_steps,
project.trajectories.iter().filter(|t| t.retracted).count(),
project
.trajectories
.iter()
.filter(|t| t.review_state.is_some())
.count(),
);
let mut cards = String::new();
for t in &project.trajectories {
let retracted_chip = if t.retracted {
r#"<span class="wb-chip wb-chip--lost">retracted</span>"#
} else {
""
};
let review_chip = t
.review_state
.as_ref()
.map(|s| {
let (c, label) = match s {
crate::bundle::ReviewState::Accepted => ("ok", "accepted"),
crate::bundle::ReviewState::Contested => ("warn", "contested"),
crate::bundle::ReviewState::NeedsRevision => ("warn", "needs revision"),
crate::bundle::ReviewState::Rejected => ("lost", "rejected"),
};
format!(r#"<span class="wb-chip wb-chip--{c}">{label}</span>"#)
})
.unwrap_or_default();
let tier_chip = if !matches!(t.access_tier, crate::access_tier::AccessTier::Public) {
format!(
r#"<span class="wb-chip wb-chip--lost">{}</span>"#,
t.access_tier.canonical()
)
} else {
String::new()
};
let targets_html = if t.target_findings.is_empty() {
String::new()
} else {
let links: Vec<String> = t
.target_findings
.iter()
.map(|f| {
format!(
r#"<a href="/findings/{f}"><code>{f}</code></a>"#,
f = escape_html(f)
)
})
.collect();
format!("<p><strong>targets:</strong> {}</p>", links.join(" · "))
};
let mut steps_html = String::new();
for (i, step) in t.steps.iter().enumerate() {
let (chip_kind, kind_label) = match step.kind {
crate::bundle::TrajectoryStepKind::Hypothesis => ("warn", "hypothesis"),
crate::bundle::TrajectoryStepKind::Tried => ("warn", "tried"),
crate::bundle::TrajectoryStepKind::RuledOut => ("lost", "ruled out"),
crate::bundle::TrajectoryStepKind::Observed => ("ok", "observed"),
crate::bundle::TrajectoryStepKind::Refined => ("ok", "refined"),
};
steps_html.push_str(&format!(
r#"<div style="border-left:2px solid var(--rule-2,#d8d4cc);padding:0.4rem 0.7rem;margin:0.3rem 0;">
<p style="margin:0 0 0.2rem 0;"><span class="wb-chip wb-chip--{chip_kind}">{i:02} · {kind_label}</span></p>
<p style="margin:0 0 0.2rem 0;">{desc}</p>
<p style="font-size:0.74rem;color:var(--ink-3,#a09a8d);margin:0;">{actor} · {at}</p>
</div>"#,
i = i + 1,
desc = escape_html(&step.description),
actor = escape_html(&step.actor),
at = escape_html(&step.at),
));
}
if t.steps.is_empty() {
steps_html.push_str(
r#"<p style="color:var(--ink-3,#a09a8d);font-style:italic;">No steps yet.</p>"#,
);
}
let notes_html = if t.notes.trim().is_empty() {
String::new()
} else {
format!(
"<p style=\"color:var(--ink-2,#6b665d);font-style:italic;\">{}</p>",
escape_html(&t.notes)
)
};
cards.push_str(&format!(
r#"<div class="wb-card">
<h3>{retracted_chip}{review_chip}{tier_chip}<code>{id}</code> · {n_steps} step(s)</h3>
{targets_html}
{notes_html}
{steps_html}
<p style="font-size:0.78rem;color:var(--ink-3,#a09a8d);">opened by {actor} · {created}</p>
</div>"#,
id = escape_html(&t.id),
n_steps = t.steps.len(),
actor = escape_html(&t.deposited_by),
created = escape_html(&t.created),
));
}
let body = format!("{stats_html}{cards}");
Html(shell(
"trajectories",
"Trajectories",
"Workbench",
&format!("{} trajector(y/ies)", project.trajectories.len()),
&body,
))
.into_response()
}
async fn page_tiers(State(state): State<AppState>) -> Response {
let project = match repo::load_from_path(&state.repo_path) {
Ok(p) => p,
Err(e) => return error_page("tiers", "Could not load frontier", &e),
};
let count_findings = |tier: crate::access_tier::AccessTier| {
project
.findings
.iter()
.filter(|f| f.access_tier == tier)
.count()
};
let count_nrs = |tier: crate::access_tier::AccessTier| {
project
.negative_results
.iter()
.filter(|n| n.access_tier == tier)
.count()
};
let count_trajs = |tier: crate::access_tier::AccessTier| {
project
.trajectories
.iter()
.filter(|t| t.access_tier == tier)
.count()
};
let public_total = count_findings(crate::access_tier::AccessTier::Public)
+ count_nrs(crate::access_tier::AccessTier::Public)
+ count_trajs(crate::access_tier::AccessTier::Public);
let restricted_total = count_findings(crate::access_tier::AccessTier::Restricted)
+ count_nrs(crate::access_tier::AccessTier::Restricted)
+ count_trajs(crate::access_tier::AccessTier::Restricted);
let classified_total = count_findings(crate::access_tier::AccessTier::Classified)
+ count_nrs(crate::access_tier::AccessTier::Classified)
+ count_trajs(crate::access_tier::AccessTier::Classified);
let stats_html = format!(
r#"<div class="wb-stats">
<div><div class="wb-stat__num">{public_total}</div><div class="wb-stat__label">public</div></div>
<div><div class="wb-stat__num">{restricted_total}</div><div class="wb-stat__label">restricted</div></div>
<div><div class="wb-stat__num">{classified_total}</div><div class="wb-stat__label">classified</div></div>
<div><div class="wb-stat__num">{cleared}</div><div class="wb-stat__label">cleared actors</div></div>
</div>"#,
cleared = project
.actors
.iter()
.filter(|a| a.access_clearance.is_some())
.count(),
);
let mut tier_events: Vec<&crate::events::StateEvent> = project
.events
.iter()
.filter(|e| e.kind == "tier.set")
.collect();
tier_events.sort_by(|a, b| b.timestamp.cmp(&a.timestamp));
let events_html = if tier_events.is_empty() {
r#"<div class="wb-card"><p>No <code>tier.set</code> events yet. Reclassify with:</p>
<p><code>vela tier-set <frontier> --object-type finding|negative_result|trajectory \
--object-id <id> --tier public|restricted|classified \
--actor <id> --reason <...></code></p></div>"#
.to_string()
} else {
let mut rows = String::new();
for e in &tier_events {
let prev_tier = e
.payload
.get("previous_tier")
.and_then(|v| v.as_str())
.unwrap_or("public");
let new_tier = e
.payload
.get("new_tier")
.and_then(|v| v.as_str())
.unwrap_or("?");
let chip_kind = match new_tier {
"public" => "ok",
"restricted" => "warn",
"classified" => "lost",
_ => "warn",
};
rows.push_str(&format!(
r#"<tr>
<td><code>{ts}</code></td>
<td><span class="wb-chip wb-chip--ok">{prev}</span> → <span class="wb-chip wb-chip--{chip_kind}">{new}</span></td>
<td><code>{ot}</code> <code>{oi}</code></td>
<td>{actor}</td>
<td>{reason}</td>
</tr>"#,
ts = escape_html(&e.timestamp),
prev = escape_html(prev_tier),
new = escape_html(new_tier),
ot = escape_html(&e.target.r#type),
oi = escape_html(&e.target.id),
actor = escape_html(&e.actor.id),
reason = escape_html(&e.reason),
));
}
format!(
r#"<table class="wb-table">
<thead><tr><th>at</th><th>change</th><th>object</th><th>actor</th><th>reason</th></tr></thead>
<tbody>{rows}</tbody>
</table>"#
)
};
let breakdown_html = format!(
r#"<div class="wb-card">
<h3>Per-collection breakdown</h3>
<table class="wb-table">
<thead><tr><th>collection</th><th>public</th><th>restricted</th><th>classified</th></tr></thead>
<tbody>
<tr><td>findings</td><td>{fp}</td><td>{fr}</td><td>{fc}</td></tr>
<tr><td>negative_results</td><td>{np}</td><td>{nr}</td><td>{nc}</td></tr>
<tr><td>trajectories</td><td>{tp}</td><td>{tr}</td><td>{tc}</td></tr>
</tbody>
</table>
</div>"#,
fp = count_findings(crate::access_tier::AccessTier::Public),
fr = count_findings(crate::access_tier::AccessTier::Restricted),
fc = count_findings(crate::access_tier::AccessTier::Classified),
np = count_nrs(crate::access_tier::AccessTier::Public),
nr = count_nrs(crate::access_tier::AccessTier::Restricted),
nc = count_nrs(crate::access_tier::AccessTier::Classified),
tp = count_trajs(crate::access_tier::AccessTier::Public),
tr = count_trajs(crate::access_tier::AccessTier::Restricted),
tc = count_trajs(crate::access_tier::AccessTier::Classified),
);
let body = format!("{stats_html}{breakdown_html}{events_html}");
Html(shell(
"tiers",
"Access tiers",
"Workbench",
"Dual-use access tiers",
&body,
))
.into_response()
}
async fn page_constellation(State(state): State<AppState>) -> Response {
let project = match repo::load_from_path(&state.repo_path) {
Ok(p) => p,
Err(e) => return error_page("constellation", "Could not load frontier", &e),
};
let n_findings = project.findings.len();
let n_links: usize = project.findings.iter().map(|f| f.links.len()).sum();
let n_cascade = project
.events
.iter()
.filter(|e| e.kind == "finding.dependency_invalidated" || e.kind == "finding.cascade_fired")
.count();
let n_retracted = project
.findings
.iter()
.filter(|f| f.flags.retracted)
.count();
let stats_html = format!(
r#"<div class="wb-stats">
<div><div class="wb-stat__num">{n_findings}</div><div class="wb-stat__label">findings</div></div>
<div><div class="wb-stat__num">{n_links}</div><div class="wb-stat__label">links</div></div>
<div><div class="wb-stat__num">{n_cascade}</div><div class="wb-stat__label">cascade events</div></div>
<div><div class="wb-stat__num">{n_retracted}</div><div class="wb-stat__label">retracted</div></div>
</div>"#
);
let svg_html = render_constellation_svg(&project);
let panel_html = r#"<aside class="vc-panel" data-vc-panel hidden>
<header class="vc-panel__head">
<span class="vc-panel__eyebrow">Selected finding</span>
<h3 class="vc-panel__title" data-vc-panel-title>—</h3>
<p class="vc-panel__id"><code data-vc-panel-id>—</code></p>
</header>
<p class="vc-panel__claim" data-vc-panel-claim>Click a node in the constellation to inspect it.</p>
<dl class="vc-panel__meta">
<div><dt>confidence</dt><dd data-vc-panel-conf>—</dd></div>
<div><dt>state</dt><dd data-vc-panel-state>—</dd></div>
<div><dt>dependents</dt><dd data-vc-panel-deps-in>—</dd></div>
<div><dt>dependencies</dt><dd data-vc-panel-deps-out>—</dd></div>
</dl>
<form class="vc-panel__cascade" data-vc-cascade-form>
<label for="vc-cascade-conf">Fire correction — drop confidence to:</label>
<input id="vc-cascade-conf" type="range" min="0" max="100" value="40" step="1" data-vc-cascade-slider>
<output data-vc-cascade-readout>0.40</output>
<button type="submit">Apply correction & cascade</button>
<p class="vc-panel__note" data-vc-cascade-status></p>
</form>
<p class="vc-panel__open"><a href="" data-vc-panel-open>→ open detail page</a></p>
</aside>"#;
let body = format!(
r#"{stats_html}
<p class="wb-eyebrow" style="margin-top:0.4rem;">Click a node to focus + open the inspector. Drag the slider, hit
"Apply correction" to drop the finding's confidence — the cascade fires
through <code>supports</code> and <code>depends</code> edges live, and any
flagged dependents pulse gold.</p>
<div class="vc-stage">
{svg_html}
{panel_html}
</div>
<style>{vc_css}</style>
<script>{vc_js}</script>"#,
vc_css = CONSTELLATION_CSS,
vc_js = CONSTELLATION_JS,
);
Html(shell(
"constellation",
"Constellation",
"Workbench",
"Live constellation",
&body,
))
.into_response()
}
#[derive(Deserialize)]
struct PropagateForm {
new_score: f64,
#[serde(default)]
reason: Option<String>,
#[serde(default)]
reviewer: Option<String>,
}
#[derive(serde::Serialize)]
struct PropagateResponse {
ok: bool,
finding_id: String,
new_confidence: f64,
affected: Vec<String>,
cascade_events: usize,
message: String,
}
async fn post_api_propagate_confidence(
AxumPath(vf_id): AxumPath<String>,
State(state): State<AppState>,
Form(body): Form<PropagateForm>,
) -> Response {
let new_score = body.new_score.clamp(0.0, 1.0);
let reviewer = body
.reviewer
.filter(|s| !s.is_empty())
.unwrap_or_else(|| "reviewer:workbench".to_string());
let reason = body
.reason
.filter(|s| !s.is_empty())
.unwrap_or_else(|| "Workbench cascade fire".to_string());
let project_before = match repo::load_from_path(&state.repo_path) {
Ok(p) => p,
Err(e) => {
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(PropagateResponse {
ok: false,
finding_id: vf_id.clone(),
new_confidence: new_score,
affected: Vec::new(),
cascade_events: 0,
message: format!("load failed: {e}"),
}),
)
.into_response();
}
};
let cascade_before = project_before
.events
.iter()
.filter(|e| e.kind == "finding.dependency_invalidated")
.count();
let opts = ReviseOptions {
confidence: new_score,
reason,
reviewer,
};
let result = state::revise_confidence(&state.repo_path, &vf_id, opts, true);
let report = match result {
Ok(r) => r,
Err(e) => {
return (
StatusCode::BAD_REQUEST,
Json(PropagateResponse {
ok: false,
finding_id: vf_id.clone(),
new_confidence: new_score,
affected: Vec::new(),
cascade_events: 0,
message: format!("revise failed: {e}"),
}),
)
.into_response();
}
};
let project_after = match repo::load_from_path(&state.repo_path) {
Ok(p) => p,
Err(e) => {
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(PropagateResponse {
ok: false,
finding_id: vf_id.clone(),
new_confidence: new_score,
affected: Vec::new(),
cascade_events: 0,
message: format!("post-load failed: {e}"),
}),
)
.into_response();
}
};
let cascade_events: Vec<&crate::events::StateEvent> = project_after
.events
.iter()
.filter(|e| e.kind == "finding.dependency_invalidated")
.collect();
let new_cascade = cascade_events.len().saturating_sub(cascade_before);
let mut affected: Vec<String> = cascade_events
.iter()
.rev()
.take(new_cascade)
.filter_map(|e| {
e.payload
.get("affected_finding")
.and_then(|v| v.as_str())
.map(String::from)
.or_else(|| Some(e.target.id.clone()))
})
.collect();
affected.sort();
affected.dedup();
(
StatusCode::OK,
Json(PropagateResponse {
ok: true,
finding_id: if report.finding_id.is_empty() {
vf_id
} else {
report.finding_id
},
new_confidence: new_score,
affected,
cascade_events: new_cascade,
message: report.message,
}),
)
.into_response()
}
fn finding_state_classes(
b: &FindingBundle,
replications: &[Replication],
) -> (&'static str, &'static str) {
use crate::bundle::ReviewState;
if b.flags.retracted {
return ("retracted", "lost");
}
if b.flags.gap || b.flags.negative_space {
return ("gap", "stale");
}
if let Some(state) = &b.flags.review_state {
match state {
ReviewState::Contested => return ("contested", "warn"),
ReviewState::NeedsRevision => return ("contested", "warn"),
ReviewState::Rejected => return ("retracted", "lost"),
ReviewState::Accepted => {
if is_replicated_for_constellation(b, replications) {
return ("replicated", "ok");
}
return ("supported", "ok");
}
}
}
if b.flags.contested {
return ("contested", "warn");
}
if is_replicated_for_constellation(b, replications) {
return ("replicated", "ok");
}
("supported", "ok")
}
fn is_replicated_for_constellation(b: &FindingBundle, replications: &[Replication]) -> bool {
let mut has_record = false;
let mut has_success = false;
for r in replications {
if r.target_finding == b.id {
has_record = true;
if r.outcome == "replicated" {
has_success = true;
}
}
}
if has_record {
has_success
} else {
b.evidence.replicated
}
}
fn render_constellation_svg(p: &Project) -> String {
if p.findings.is_empty() {
return String::from(
r#"<p class="vc-empty">No findings yet — deposit one with <code>vela finding add</code>.</p>"#,
);
}
let n = p.findings.len();
let view_w: i32 = 720;
let view_h: i32 = 380;
let cx = view_w as f64 / 2.0;
let cy = view_h as f64 / 2.0;
let ring_r = (cx.min(cy) - 60.0).max(80.0);
let pos: std::collections::HashMap<&str, (f64, f64)> = p
.findings
.iter()
.enumerate()
.map(|(i, b)| {
let angle = (i as f64 / n as f64) * std::f64::consts::TAU - std::f64::consts::FRAC_PI_2;
let x = cx + ring_r * angle.cos();
let y = cy + ring_r * angle.sin();
(b.id.as_str(), (x, y))
})
.collect();
let mut deps_out: std::collections::HashMap<&str, u32> = std::collections::HashMap::new();
let mut deps_in: std::collections::HashMap<&str, u32> = std::collections::HashMap::new();
for b in &p.findings {
let from = b.id.as_str();
for link in &b.links {
*deps_out.entry(from).or_default() += 1;
if pos.contains_key(link.target.as_str()) {
*deps_in.entry(link.target.as_str()).or_default() += 1;
}
}
}
let mut edges = String::new();
for b in &p.findings {
let Some(&(x1, y1)) = pos.get(b.id.as_str()) else {
continue;
};
let from = escape_html(&b.id);
for link in &b.links {
if let Some(&(x2, y2)) = pos.get(link.target.as_str()) {
let mx = (x1 + x2) / 2.0;
let my = (y1 + y2) / 2.0;
let pull = 0.45;
let qx = cx + (mx - cx) * pull;
let qy = cy + (my - cy) * pull;
let to = escape_html(&link.target);
let lt = escape_html(&link.link_type);
edges.push_str(&format!(
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}"/>"##
));
} else {
let dx = x1 - cx;
let dy = y1 - cy;
let mag = (dx * dx + dy * dy).sqrt().max(1e-6);
let conf = b.confidence.score.clamp(0.0, 1.0);
let outward = 18.0 + conf * 22.0;
let xt = x1 + (dx / mag) * outward;
let yt = y1 + (dy / mag) * outward;
edges.push_str(&format!(
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}"/>"##
));
}
}
}
let mut nodes = String::new();
for b in &p.findings {
let (x, y) = pos[b.id.as_str()];
let (label, state_class) = finding_state_classes(b, &p.replications);
let r = 4.0 + b.confidence.score.clamp(0.0, 1.0) * 5.0;
let live_class = if label == "replicated" {
" vc-node--live"
} else {
""
};
let vf = escape_html(&b.id);
let claim = escape_html(&b.assertion.text);
let n_out = deps_out.get(b.id.as_str()).copied().unwrap_or(0);
let n_in = deps_in.get(b.id.as_str()).copied().unwrap_or(0);
let conf = b.confidence.score;
let href = format!("/findings/{}", escape_html(&b.id));
nodes.push_str(&format!(
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}">
<circle class="vc-glow" cx="{x:.1}" cy="{y:.1}" r="{rg:.1}"/>
<circle class="vc-dot" cx="{x:.1}" cy="{y:.1}" r="{r:.1}" style="fill:var(--state-{state_class});"/>
</a>"#,
rg = r * 2.6,
));
}
format!(
r#"<figure class="vc-figure" data-vc-figure>
<svg class="vc" viewBox="0 0 {w} {h}" preserveAspectRatio="xMidYMid meet" role="img" aria-label="Finding constellation — {n} findings as a star chart">
<circle class="vc-ring" cx="{cx}" cy="{cy}" r="{rr}"/>
<circle class="vc-center" cx="{cx}" cy="{cy}" r="2.5"/>
<g class="vc-edges">{edges}</g>
<g class="vc-nodes">{nodes}</g>
</svg>
<p class="vc-tooltip" data-vc-tooltip aria-hidden="true"></p>
<p class="vc-legend">
<span><span class="vc-legend__dot" style="background:#3b7a48;"></span>replicated · supported</span>
<span class="vc-sep">·</span>
<span><span class="vc-legend__dot" style="background:#a07a1f;"></span>contested</span>
<span class="vc-sep">·</span>
<span><span class="vc-legend__dot" style="background:#7d7d7d;"></span>gap · inferred</span>
<span class="vc-sep">·</span>
<span><span class="vc-legend__dot" style="background:#9b3232;"></span>retracted</span>
<span class="vc-sep">·</span>
<span><span class="vc-legend__dot" style="background:#3a6a8a;"></span>cross-frontier</span>
<span class="vc-sep">·</span>
<span>radius = confidence · click to focus · esc to clear</span>
</p>
</figure>"#,
w = view_w,
h = view_h,
rr = ring_r,
)
}
const CONSTELLATION_CSS: &str = r#"
:root {
--vc-gold: #c79a3a;
--vc-gold-glow: rgba(199, 154, 58, 0.55);
--vc-winter: #3a6a8a;
--state-ok: #3b7a48;
--state-warn: #a07a1f;
--state-stale: #7d7d7d;
--state-lost: #9b3232;
}
.vc-stage { display: grid; grid-template-columns: minmax(0, 1fr) 320px; gap: 1.25rem; align-items: start; }
@media (max-width: 980px) { .vc-stage { grid-template-columns: 1fr; } }
.vc-figure {
margin: 0;
background: #1c1d22;
border: 1px solid var(--rule-2, #d8d4cc);
border-radius: 4px;
overflow: hidden;
position: relative;
}
.vc { display: block; width: 100%; height: auto; max-height: 460px;
background: radial-gradient(circle at 50% 50%, rgba(199,154,58,0.10) 0%, transparent 38%), #1c1d22; }
.vc-ring { fill: none; stroke: rgba(199,154,58,0.28); stroke-width: 0.6; stroke-dasharray: 1 5; }
.vc-center { fill: var(--vc-gold); filter: drop-shadow(0 0 6px var(--vc-gold-glow)); }
.vc-edges { fill: none; stroke: rgba(199,154,58,0.34); stroke-width: 0.7; pointer-events: none; }
.vc-edge { transition: stroke 200ms ease, stroke-width 200ms ease, opacity 200ms ease; }
.vc-edge--cross { stroke: rgba(58,106,138,0.62); stroke-width: 0.85; stroke-linecap: round; }
.vc-edge--cascade { stroke: var(--vc-gold) !important; stroke-width: 2 !important; opacity: 1 !important;
filter: drop-shadow(0 0 4px var(--vc-gold-glow));
stroke-dasharray: 6 4; animation: vc-flow 1.2s linear infinite; }
@keyframes vc-flow { from { stroke-dashoffset: 0; } to { stroke-dashoffset: -20; } }
.vc-node { cursor: pointer; outline: none; transition: opacity 200ms ease; }
.vc-glow { fill: var(--vc-gold); opacity: 0; transition: opacity 200ms ease; pointer-events: none; }
.vc-node:hover .vc-glow, .vc-node:focus .vc-glow { opacity: 0.32; }
.vc-dot { transition: r 200ms ease, stroke 200ms ease, stroke-width 200ms ease;
stroke: rgba(255,255,255,0.20); stroke-width: 0.5; }
.vc-node:hover .vc-dot, .vc-node:focus .vc-dot { stroke: #fff; stroke-width: 1; }
.vc-node--live .vc-dot { filter: drop-shadow(0 0 4px var(--vc-gold-glow)); }
.vc-node--live .vc-glow { opacity: 0.18; }
.vc--focused .vc-node { opacity: 0.22; }
.vc--focused .vc-node--focus { opacity: 1; }
.vc--focused .vc-node--related { opacity: 1; }
.vc--focused .vc-edge { opacity: 0.16; }
.vc--focused .vc-edge--focus { opacity: 1; stroke: var(--vc-gold); stroke-width: 1.4; }
.vc--focused .vc-ring { opacity: 0.4; }
.vc--focused .vc-center { opacity: 0.5; }
.vc-node--focus .vc-glow { opacity: 0.42; }
.vc-node--focus .vc-dot { stroke: #fff; stroke-width: 1.4; }
.vc-node--cascade-hit .vc-dot { stroke: var(--vc-gold); stroke-width: 2.2;
filter: drop-shadow(0 0 8px var(--vc-gold-glow)); }
.vc-node--cascade-hit .vc-glow { opacity: 0.55; }
.vc-tooltip { margin: 0; padding: 10px 14px 12px; border-top: 1px solid #303237;
font-size: 13px; line-height: 1.4; color: #e6e2d6; min-height: 1.4em;
background: #232428; opacity: 1; transition: opacity 200ms ease; }
.vc-tooltip:empty::before { content: 'Hover a node to read the claim · click to focus.';
color: #8c8a82; font-style: italic; }
.vc-tooltip__meta { font-family: ui-monospace, Menlo, monospace; font-size: 11px;
font-weight: 400; letter-spacing: 0.04em; color: #a8a39a; }
.vc-legend { margin: 0; padding: 8px 14px 12px; font-family: ui-monospace, Menlo, monospace;
font-size: 10px; letter-spacing: 0.14em; text-transform: uppercase; color: #a8a39a;
display: flex; flex-wrap: wrap; gap: 4px 10px; align-items: center;
border-top: 1px solid #2c2d31; background: transparent; }
.vc-legend > span { display: inline-flex; align-items: center; gap: 4px; }
.vc-legend__dot { display: inline-block; width: 6px; height: 6px; border-radius: 50%; }
.vc-legend .vc-sep { color: #5c5b56; }
.vc-empty { padding: 1.5rem; color: var(--ink-2, #6b665d); }
.vc-panel { background: var(--bg-2, #f5f2ec); border: 1px solid var(--rule-2, #d8d4cc);
padding: 1rem 1.1rem; font-size: 0.92rem; }
.vc-panel[hidden] { display: none; }
.vc-panel__head { margin-bottom: 0.6rem; }
.vc-panel__eyebrow { font-size: 0.72rem; text-transform: uppercase; letter-spacing: 0.08em;
color: var(--ink-2, #6b665d); }
.vc-panel__title { margin: 0.2rem 0; font-size: 1rem; }
.vc-panel__id { margin: 0; font-size: 0.78rem; color: var(--ink-2, #6b665d); }
.vc-panel__claim { margin: 0.6rem 0 0.8rem; line-height: 1.5; }
.vc-panel__meta { display: grid; grid-template-columns: 1fr 1fr; gap: 0.3rem 0.8rem;
margin: 0 0 1rem 0; font-size: 0.84rem; }
.vc-panel__meta div { display: flex; flex-direction: column; }
.vc-panel__meta dt { font-size: 0.7rem; text-transform: uppercase; letter-spacing: 0.06em;
color: var(--ink-2, #6b665d); }
.vc-panel__meta dd { margin: 0.05rem 0 0 0; font-family: ui-monospace, Menlo, monospace;
font-size: 0.86rem; }
.vc-panel__cascade { display: flex; flex-direction: column; gap: 0.4rem;
border-top: 1px solid var(--rule-2, #d8d4cc); padding-top: 0.8rem; margin-top: 0.4rem; }
.vc-panel__cascade label { font-size: 0.78rem; color: var(--ink-2, #6b665d); }
.vc-panel__cascade input[type=range] { width: 100%; }
.vc-panel__cascade output { font-family: ui-monospace, Menlo, monospace; font-size: 0.92rem;
font-weight: 600; }
.vc-panel__cascade button { font-family: inherit; font-size: 0.84rem; padding: 0.4rem 0.7rem;
border: 1px solid #1a1a1a; background: #1a1a1a; color: #fff; cursor: pointer; border-radius: 2px; }
.vc-panel__cascade button:disabled { opacity: 0.5; cursor: wait; }
.vc-panel__note { margin: 0.4rem 0 0 0; font-size: 0.82rem; color: var(--ink-2, #6b665d);
min-height: 1.1em; line-height: 1.4; }
.vc-panel__note.is-success { color: #2f5d3a; }
.vc-panel__note.is-error { color: #872c2c; }
.vc-panel__open { margin: 0.8rem 0 0 0; font-size: 0.82rem; }
.vc-panel__open a { color: #1a1a1a; }
"#;
const CONSTELLATION_JS: &str = r#"
(function(){
var fig = document.querySelector('[data-vc-figure]');
var panel = document.querySelector('[data-vc-panel]');
if (!fig || !panel) return;
var nodes = fig.querySelectorAll('.vc-node');
var edges = fig.querySelectorAll('.vc-edge');
var tip = fig.querySelector('[data-vc-tooltip]');
var focused = null;
var pTitle = panel.querySelector('[data-vc-panel-title]');
var pId = panel.querySelector('[data-vc-panel-id]');
var pClaim = panel.querySelector('[data-vc-panel-claim]');
var pConf = panel.querySelector('[data-vc-panel-conf]');
var pState = panel.querySelector('[data-vc-panel-state]');
var pIn = panel.querySelector('[data-vc-panel-deps-in]');
var pOut = panel.querySelector('[data-vc-panel-deps-out]');
var pOpen = panel.querySelector('[data-vc-panel-open]');
var form = panel.querySelector('[data-vc-cascade-form]');
var slider = panel.querySelector('[data-vc-cascade-slider]');
var readout = panel.querySelector('[data-vc-cascade-readout]');
var status = panel.querySelector('[data-vc-cascade-status]');
var button = form ? form.querySelector('button') : null;
function clearTip(){ tip.innerHTML = ''; }
function showTipFromNode(n){
var claim = n.getAttribute('data-claim') || '';
var nOut = parseInt(n.getAttribute('data-deps-out') || '0', 10);
var nIn = parseInt(n.getAttribute('data-deps-in') || '0', 10);
var meta = nOut + ' dep' + (nOut === 1 ? '' : 's') + ' · ' + nIn + ' dependent' + (nIn === 1 ? '' : 's');
tip.innerHTML = claim + ' <span class="vc-tooltip__meta">· ' + meta + '</span>';
}
function relatedSet(vf){
var related = {};
edges.forEach(function(e){
var from = e.getAttribute('data-from');
var to = e.getAttribute('data-to');
if (from === vf) { related[to] = true; e.classList.add('vc-edge--focus'); }
else if (to === vf) { related[from] = true; e.classList.add('vc-edge--focus'); }
else { e.classList.remove('vc-edge--focus'); }
});
return related;
}
function fillPanel(node){
var vf = node.getAttribute('data-vf');
var claim = node.getAttribute('data-claim') || '';
var conf = parseFloat(node.getAttribute('data-conf') || '0');
var st = node.getAttribute('data-state') || '—';
var nOut = parseInt(node.getAttribute('data-deps-out') || '0', 10);
var nIn = parseInt(node.getAttribute('data-deps-in') || '0', 10);
pTitle.textContent = vf;
pId.textContent = vf;
pClaim.textContent = claim;
pConf.textContent = conf.toFixed(3);
pState.textContent = st;
pIn.textContent = String(nIn);
pOut.textContent = String(nOut);
pOpen.setAttribute('href', '/findings/' + vf);
panel.removeAttribute('hidden');
if (status) { status.textContent = ''; status.classList.remove('is-success','is-error'); }
if (button) { button.disabled = false; }
}
function applyFocus(node){
var vf = node.getAttribute('data-vf');
focused = vf;
fig.classList.add('vc--focused');
var related = relatedSet(vf);
nodes.forEach(function(n){
var nv = n.getAttribute('data-vf');
n.classList.remove('vc-node--focus','vc-node--related','vc-node--cascade-hit');
if (nv === vf) n.classList.add('vc-node--focus');
else if (related[nv]) n.classList.add('vc-node--related');
});
edges.forEach(function(e){ e.classList.remove('vc-edge--cascade'); });
showTipFromNode(node);
fillPanel(node);
}
function clearFocus(){
focused = null;
fig.classList.remove('vc--focused');
nodes.forEach(function(n){ n.classList.remove('vc-node--focus','vc-node--related'); });
edges.forEach(function(e){ e.classList.remove('vc-edge--focus'); });
clearTip();
}
nodes.forEach(function(n){
n.addEventListener('mouseenter', function(){ if (!focused) showTipFromNode(n); });
n.addEventListener('mouseleave', function(){ if (!focused) clearTip(); });
n.addEventListener('click', function(e){
var vf = n.getAttribute('data-vf');
if (focused === vf) { return; } // second click → navigate
e.preventDefault();
applyFocus(n);
});
});
document.addEventListener('keydown', function(e){
if (e.key === 'Escape' && focused) { clearFocus(); }
});
if (slider && readout) {
var sync = function(){ readout.textContent = (slider.value/100).toFixed(2); };
slider.addEventListener('input', sync);
sync();
}
if (form) {
form.addEventListener('submit', function(e){
e.preventDefault();
if (!focused) { return; }
var newScore = (slider ? slider.value : 40) / 100;
var fd = new URLSearchParams();
fd.append('new_score', String(newScore));
fd.append('reason', 'Workbench cascade fire from constellation');
fd.append('reviewer', 'reviewer:workbench');
if (button) button.disabled = true;
if (status) { status.textContent = 'firing cascade…'; status.classList.remove('is-success','is-error'); }
fetch('/api/propagate/' + encodeURIComponent(focused), {
method: 'POST',
headers: {'Content-Type': 'application/x-www-form-urlencoded'},
body: fd.toString()
}).then(function(r){ return r.json(); }).then(function(j){
if (button) button.disabled = false;
if (!j.ok) {
if (status) { status.textContent = j.message || 'cascade failed'; status.classList.add('is-error'); }
return;
}
var hits = (j.affected || []);
if (status) {
status.classList.add('is-success');
status.textContent = 'cascade fired · confidence ' + j.new_confidence.toFixed(2) +
' · ' + j.cascade_events + ' downstream flagged';
}
// Animate gold edges from focused → each affected.
var fromVf = focused;
edges.forEach(function(e){
var from = e.getAttribute('data-from');
var to = e.getAttribute('data-to');
if ((from === fromVf && hits.indexOf(to) >= 0) ||
(to === fromVf && hits.indexOf(from) >= 0)) {
e.classList.add('vc-edge--cascade');
}
});
// Pulse hit nodes.
nodes.forEach(function(n){
if (hits.indexOf(n.getAttribute('data-vf')) >= 0) {
n.classList.add('vc-node--cascade-hit');
}
});
// Update local conf readout for source.
if (pConf) pConf.textContent = j.new_confidence.toFixed(3);
}).catch(function(err){
if (button) button.disabled = false;
if (status) { status.textContent = String(err); status.classList.add('is-error'); }
});
});
}
})();
"#;
async fn page_replay(AxumPath(vf_id): AxumPath<String>, State(state): State<AppState>) -> Response {
let payload = match state::history_as_of(&state.repo_path, &vf_id, None) {
Ok(v) => v,
Err(e) => return error_page("replay", "history failed", &e),
};
let project = match repo::load_from_path(&state.repo_path) {
Ok(p) => p,
Err(e) => return error_page("replay", "load failed", &e),
};
let assertion = payload
.pointer("/finding/assertion")
.and_then(|v| v.as_str())
.unwrap_or("(no assertion)")
.to_string();
let current_conf = payload
.pointer("/finding/confidence")
.and_then(|v| v.as_f64())
.unwrap_or(0.0);
#[derive(Clone)]
struct ReplayPoint {
ts: String,
kind: String,
previous: Option<f64>,
new: Option<f64>,
reason: String,
}
let empty = Vec::new();
let events = payload
.pointer("/events")
.and_then(|v| v.as_array())
.unwrap_or(&empty);
let mut points: Vec<ReplayPoint> = events
.iter()
.map(|e| ReplayPoint {
ts: e
.get("timestamp")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string(),
kind: e
.get("kind")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string(),
previous: e
.pointer("/payload/previous_score")
.and_then(|v| v.as_f64()),
new: e
.pointer("/payload/new_score")
.and_then(|v| v.as_f64())
.or_else(|| e.pointer("/payload/confidence").and_then(|v| v.as_f64())),
reason: e
.get("reason")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string(),
})
.collect();
points.sort_by(|a, b| a.ts.cmp(&b.ts));
let mut series: Vec<(String, f64, String)> = Vec::new();
let mut last = if let Some(p) = points.first() {
p.previous.unwrap_or(current_conf)
} else {
current_conf
};
for p in &points {
let score = p.new.unwrap_or_else(|| p.previous.unwrap_or(last));
series.push((p.ts.clone(), score, p.kind.clone()));
last = score;
}
if series.is_empty() {
series.push((String::new(), current_conf, "current".to_string()));
}
let view_w = 720i32;
let view_h = 140i32;
let pad_l = 40.0;
let pad_r = 20.0;
let pad_t = 16.0;
let pad_b = 28.0;
let plot_w = view_w as f64 - pad_l - pad_r;
let plot_h = view_h as f64 - pad_t - pad_b;
let n = series.len() as f64;
let mut path = String::new();
let mut points_svg = String::new();
for (i, (_, score, kind)) in series.iter().enumerate() {
let x = pad_l + (i as f64 / (n - 1.0).max(1.0)) * plot_w;
let y = pad_t + (1.0 - score.clamp(0.0, 1.0)) * plot_h;
if i == 0 {
path.push_str(&format!("M {x:.1} {y:.1}"));
} else {
path.push_str(&format!(" L {x:.1} {y:.1}"));
}
let dot_class = match kind.as_str() {
"finding.asserted" => "rp-dot rp-dot--genesis",
"finding.confidence_revised" => "rp-dot rp-dot--revise",
"finding.retracted" | "finding.flagged" => "rp-dot rp-dot--retract",
"finding.dependency_invalidated" => "rp-dot rp-dot--cascade",
_ => "rp-dot",
};
points_svg.push_str(&format!(
r#"<circle class="{dot_class}" cx="{x:.1}" cy="{y:.1}" r="3.5"><title>{score:.2} · {kind}</title></circle>"#,
));
}
let threshold_y = pad_t + (1.0 - 0.5) * plot_h;
let svg = format!(
r#"<svg class="rp-svg" viewBox="0 0 {view_w} {view_h}" preserveAspectRatio="xMidYMid meet" role="img" aria-label="Confidence trajectory over time">
<line class="rp-axis" x1="{pad_l}" y1="{ax_y}" x2="{x_end}" y2="{ax_y}"/>
<line class="rp-axis" x1="{pad_l}" y1="{pad_t}" x2="{pad_l}" y2="{ax_y}"/>
<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>
<text class="rp-label" x="6" y="{pad_t}" dy="0.32em">1.0</text>
<text class="rp-label" x="6" y="{ax_y}" dy="0.32em">0.0</text>
<text class="rp-label" x="6" y="{threshold_y:.1}" dy="0.32em">0.5</text>
<path class="rp-line" d="{path}"/>
{points_svg}
</svg>"#,
ax_y = pad_t + plot_h,
x_end = pad_l + plot_w,
);
let mut rows = String::new();
for p in points.iter().rev() {
let kind_chip = match p.kind.as_str() {
"finding.asserted" => ("ok", "asserted"),
"finding.confidence_revised" => ("warn", "revised"),
"finding.retracted" => ("lost", "retracted"),
"finding.dependency_invalidated" => ("warn", "cascade"),
"finding.reviewed" => ("ok", "reviewed"),
"finding.flagged" => ("warn", "flagged"),
_ => ("warn", p.kind.as_str()),
};
let from = p
.previous
.map(|v| format!("{v:.2}"))
.unwrap_or("—".to_string());
let to = p.new.map(|v| format!("{v:.2}")).unwrap_or("—".to_string());
rows.push_str(&format!(
r#"<tr>
<td><code>{ts}</code></td>
<td><span class="wb-chip wb-chip--{c}">{label}</span></td>
<td><code>{from}</code> → <code>{to}</code></td>
<td>{reason}</td>
</tr>"#,
ts = escape_html(&p.ts),
c = kind_chip.0,
label = escape_html(kind_chip.1),
reason = escape_html(&p.reason),
));
}
if rows.is_empty() {
rows =
r#"<tr><td colspan="4">No events recorded for this finding yet.</td></tr>"#.to_string();
}
let n_revisions = points
.iter()
.filter(|p| p.kind == "finding.confidence_revised")
.count();
let n_cascades = points
.iter()
.filter(|p| p.kind == "finding.dependency_invalidated")
.count();
let stats = format!(
r#"<div class="wb-stats">
<div><div class="wb-stat__num">{}</div><div class="wb-stat__label">events</div></div>
<div><div class="wb-stat__num">{n_revisions}</div><div class="wb-stat__label">revisions</div></div>
<div><div class="wb-stat__num">{n_cascades}</div><div class="wb-stat__label">cascade hits</div></div>
<div><div class="wb-stat__num">{current_conf:.2}</div><div class="wb-stat__label">current</div></div>
</div>"#,
points.len(),
);
let _ = project;
let body = format!(
r#"{stats}
<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>
<p style="font-size:0.95rem;line-height:1.5;margin:0.4rem 0 1rem;">{claim}</p>
<div class="rp-figure">{svg}</div>
<p class="rp-legend">
<span><span class="rp-legend__dot" style="background:#3b7a48;"></span>asserted</span>
<span><span class="rp-legend__dot" style="background:#a07a1f;"></span>revised</span>
<span><span class="rp-legend__dot" style="background:#9b3232;"></span>retracted</span>
<span><span class="rp-legend__dot" style="background:#c79a3a;"></span>cascade hit</span>
</p>
<table class="wb-table">
<thead><tr><th>at</th><th>kind</th><th>score</th><th>reason</th></tr></thead>
<tbody>{rows}</tbody>
</table>
<style>{css}</style>
<p style="margin-top:1rem;font-size:0.86rem;"><a href="/findings/{vf}">← back to finding detail</a> · <a href="/constellation">← constellation</a></p>"#,
vf = escape_html(&vf_id),
claim = escape_html(&assertion),
css = REPLAY_CSS,
);
Html(shell(
"constellation",
"Time-travel replay",
"Workbench",
"Time-travel replay",
&body,
))
.into_response()
}
const REPLAY_CSS: &str = r#"
.rp-figure { background: #1c1d22; border: 1px solid var(--rule-2, #d8d4cc); border-radius: 4px; padding: 0; margin: 1rem 0 0.5rem; overflow: hidden; }
.rp-svg { display: block; width: 100%; height: auto; max-height: 200px;
background: radial-gradient(circle at 50% 50%, rgba(199,154,58,0.10) 0%, transparent 38%), #1c1d22; }
.rp-axis { stroke: rgba(255,255,255,0.18); stroke-width: 0.7; }
.rp-threshold { stroke: rgba(199,154,58,0.55); stroke-width: 0.6; stroke-dasharray: 3 3; }
.rp-label { font-family: ui-monospace, Menlo, monospace; font-size: 9px; fill: #a8a39a; }
.rp-line { fill: none; stroke: #c79a3a; stroke-width: 1.6; filter: drop-shadow(0 0 4px rgba(199,154,58,0.45)); }
.rp-dot { stroke: #1c1d22; stroke-width: 1; fill: #c79a3a; }
.rp-dot--genesis { fill: #3b7a48; }
.rp-dot--revise { fill: #a07a1f; }
.rp-dot--retract { fill: #9b3232; }
.rp-dot--cascade { fill: #c79a3a; }
.rp-legend { margin: 0.3rem 0 1rem; font-family: ui-monospace, Menlo, monospace; font-size: 10px;
letter-spacing: 0.14em; text-transform: uppercase; color: var(--ink-2, #6b665d);
display: flex; flex-wrap: wrap; gap: 4px 12px; }
.rp-legend > span { display: inline-flex; align-items: center; gap: 4px; }
.rp-legend__dot { display: inline-block; width: 8px; height: 8px; border-radius: 50%; }
"#;
#[derive(Deserialize)]
struct ProposalDecisionForm {
#[serde(default)]
reviewer: Option<String>,
#[serde(default)]
reason: Option<String>,
}
fn proposal_decision(form: ProposalDecisionForm) -> (String, String) {
let reviewer = form
.reviewer
.filter(|value| !value.trim().is_empty())
.unwrap_or_else(|| "reviewer:workbench".to_string());
let reason = form
.reason
.filter(|value| !value.trim().is_empty())
.unwrap_or_else(|| "Workbench review decision.".to_string());
(reviewer, reason)
}
async fn post_proposal_accept(
AxumPath(vpr_id): AxumPath<String>,
State(state): State<AppState>,
Form(form): Form<ProposalDecisionForm>,
) -> Response {
let (reviewer, reason) = proposal_decision(form);
match proposals::accept_at_path(&state.repo_path, &vpr_id, &reviewer, &reason) {
Ok(_) => Redirect::to("/proposals").into_response(),
Err(e) => error_page("proposals", "Could not accept proposal", &e),
}
}
async fn post_proposal_reject(
AxumPath(vpr_id): AxumPath<String>,
State(state): State<AppState>,
Form(form): Form<ProposalDecisionForm>,
) -> Response {
let (reviewer, reason) = proposal_decision(form);
match proposals::reject_at_path(&state.repo_path, &vpr_id, &reviewer, &reason) {
Ok(()) => Redirect::to("/proposals").into_response(),
Err(e) => error_page("proposals", "Could not reject proposal", &e),
}
}
async fn post_proposal_revision(
AxumPath(vpr_id): AxumPath<String>,
State(state): State<AppState>,
Form(form): Form<ProposalDecisionForm>,
) -> Response {
let (reviewer, reason) = proposal_decision(form);
match proposals::request_revision_at_path(&state.repo_path, &vpr_id, &reviewer, &reason) {
Ok(()) => Redirect::to("/proposals").into_response(),
Err(e) => error_page("proposals", "Could not request revision", &e),
}
}
async fn post_bridge_confirm(
AxumPath(vbr_id): AxumPath<String>,
State(state): State<AppState>,
) -> Response {
set_bridge_status(&state.repo_path, &vbr_id, BridgeStatus::Confirmed);
Redirect::to("/bridges").into_response()
}
async fn post_bridge_refute(
AxumPath(vbr_id): AxumPath<String>,
State(state): State<AppState>,
) -> Response {
set_bridge_status(&state.repo_path, &vbr_id, BridgeStatus::Refuted);
Redirect::to("/bridges").into_response()
}
fn bridges_dir(repo_path: &Path) -> PathBuf {
repo_path.join(".vela/bridges")
}
fn list_bridges(repo_path: &Path) -> Vec<Bridge> {
let dir = bridges_dir(repo_path);
if !dir.is_dir() {
return Vec::new();
}
let mut out = Vec::new();
if let Ok(entries) = std::fs::read_dir(&dir) {
for e in entries.flatten() {
let p = e.path();
if p.extension().and_then(|s| s.to_str()) != Some("json") {
continue;
}
if let Ok(data) = std::fs::read_to_string(&p)
&& let Ok(b) = serde_json::from_str::<Bridge>(&data)
{
out.push(b);
}
}
}
out.sort_by(|a, b| {
b.finding_refs
.len()
.cmp(&a.finding_refs.len())
.then(a.entity_name.cmp(&b.entity_name))
});
out
}
fn set_bridge_status(repo_path: &Path, vbr_id: &str, status: BridgeStatus) {
let p = bridges_dir(repo_path).join(format!("{vbr_id}.json"));
let Ok(data) = std::fs::read_to_string(&p) else {
return;
};
let Ok(mut b) = serde_json::from_str::<Bridge>(&data) else {
return;
};
b.status = status;
if let Ok(out) = serde_json::to_string_pretty(&b) {
let _ = std::fs::write(&p, format!("{out}\n"));
}
}
async fn static_tokens_css() -> Response {
css_response(TOKENS_CSS)
}
async fn static_workbench_css() -> Response {
css_response(WORKBENCH_CSS)
}
async fn static_favicon_svg() -> Response {
svg_response(FAVICON_SVG)
}
async fn healthz() -> Response {
(StatusCode::OK, "ok").into_response()
}
fn css_response(body: &'static str) -> Response {
(
StatusCode::OK,
[
(axum::http::header::CONTENT_TYPE, "text/css; charset=utf-8"),
(axum::http::header::CACHE_CONTROL, "public, max-age=300"),
],
body,
)
.into_response()
}
fn svg_response(body: &'static str) -> Response {
(
StatusCode::OK,
[
(axum::http::header::CONTENT_TYPE, "image/svg+xml"),
(axum::http::header::CACHE_CONTROL, "public, max-age=300"),
],
body,
)
.into_response()
}
fn error_page(active: &str, title: &str, message: &str) -> Response {
let body = format!(
r#"<div class="wb-card"><h3>{title}</h3><p>{msg}</p></div>"#,
title = escape_html(title),
msg = escape_html(message)
);
let html = shell(active, title, "Workbench", title, &body);
(StatusCode::INTERNAL_SERVER_ERROR, Html(html)).into_response()
}
fn default_reviewer() -> String {
std::env::var("VELA_REVIEWER_ID").unwrap_or_else(|_| "reviewer:will-blair".to_string())
}
fn actor_datalist(project: &Project) -> String {
if project.actors.is_empty() {
return String::new();
}
let mut html = String::from(r#"<datalist id="vela-actors">"#);
for actor in &project.actors {
html.push_str(&format!(r#"<option value="{}">"#, escape_html(&actor.id)));
}
html.push_str("</datalist>");
html
}
#[derive(Debug, Deserialize)]
struct LocatorRepairForm {
atom_id: String,
locator: String,
reviewer: String,
reason: String,
}
#[derive(Debug, Deserialize)]
struct SpanRepairForm {
finding_id: String,
section: String,
text: String,
reviewer: String,
reason: String,
}
#[derive(Debug, Deserialize)]
struct EntityResolveForm {
finding_id: String,
entity_name: String,
source: String,
id: String,
confidence: f64,
matched_name: Option<String>,
resolution_method: String,
reviewer: String,
reason: String,
}
#[derive(Debug, Deserialize)]
struct PromoteForm {
finding_id: String,
status: String,
reviewer: String,
reason: String,
}
#[derive(Debug, Deserialize)]
struct ConflictResolveForm {
conflict_event_id: String,
resolution_note: String,
reviewer: String,
winning_proposal_id: Option<String>,
}
#[derive(Debug, Deserialize)]
struct ReplicationAddForm {
finding_id: String,
outcome: String,
attempted_by: String,
conditions_text: String,
source_title: String,
#[serde(default)]
doi: String,
#[serde(default)]
pmid: String,
#[serde(default)]
note: String,
}
#[derive(Debug, Deserialize)]
struct PredictionAddForm {
finding_id: String,
claim_text: String,
resolves_by: String,
resolution_criterion: String,
expected_outcome: String,
made_by: String,
confidence: f64,
conditions_text: String,
}
#[derive(Debug, Deserialize, Default)]
struct InboxFilter {
#[serde(default)]
source: String,
}
async fn page_review_inbox(
State(state): State<AppState>,
Query(filter): Query<InboxFilter>,
) -> Response {
let project = match repo::load_from_path(&state.repo_path) {
Ok(p) => p,
Err(e) => return error_page("review", "Could not load frontier", &e),
};
let locator_gaps: Vec<&crate::sources::EvidenceAtom> = project
.evidence_atoms
.iter()
.filter(|a| a.locator.is_none())
.take(20)
.collect();
let span_gaps: Vec<&FindingBundle> = project
.findings
.iter()
.filter(|f| f.evidence.evidence_spans.is_empty())
.take(20)
.collect();
let entity_gaps: Vec<&FindingBundle> = project
.findings
.iter()
.filter(|f| f.assertion.entities.iter().any(|e| e.needs_review))
.take(20)
.collect();
let link_gaps: Vec<&FindingBundle> = project
.findings
.iter()
.filter(|f| f.links.is_empty())
.take(20)
.collect();
let source_filter = filter.source.trim().to_ascii_lowercase();
let matches_source_filter = |f: &FindingBundle| -> bool {
if source_filter.is_empty() {
return true;
}
let doi_match = f
.provenance
.doi
.as_deref()
.map(|d| d.to_ascii_lowercase())
.map(|d| {
d.starts_with(&source_filter) || format!("doi:{d}").starts_with(&source_filter)
})
.unwrap_or(false);
let pmid_match = f
.provenance
.pmid
.as_deref()
.map(|p| p.to_ascii_lowercase())
.map(|p| {
p.starts_with(&source_filter) || format!("pmid:{p}").starts_with(&source_filter)
})
.unwrap_or(false);
doi_match || pmid_match
};
let promote_pending: Vec<&FindingBundle> = project
.findings
.iter()
.filter(|f| {
matches!(
f.flags.review_state,
None | Some(crate::bundle::ReviewState::NeedsRevision)
)
})
.filter(|f| matches_source_filter(f))
.take(20)
.collect();
let total_promote = project
.findings
.iter()
.filter(|f| {
matches!(
f.flags.review_state,
None | Some(crate::bundle::ReviewState::NeedsRevision)
)
})
.count();
let federation_conflicts: Vec<&crate::events::StateEvent> = project
.events
.iter()
.filter(|e| e.kind == "frontier.conflict_detected")
.rev()
.take(20)
.collect();
let total_conflicts = project
.events
.iter()
.filter(|e| e.kind == "frontier.conflict_detected")
.count();
let total_locator = project
.evidence_atoms
.iter()
.filter(|a| a.locator.is_none())
.count();
let total_span = project
.findings
.iter()
.filter(|f| f.evidence.evidence_spans.is_empty())
.count();
let total_entity = project
.findings
.iter()
.filter(|f| f.assertion.entities.iter().any(|e| e.needs_review))
.count();
let total_link = project
.findings
.iter()
.filter(|f| f.links.is_empty())
.count();
let mut body = String::new();
body.push_str(&format!(
r#"<form method="get" action="/review/inbox" style="margin:0 0 0.6rem 0;display:flex;gap:0.5rem;align-items:center;">
<label for="wb-source-filter" style="color:var(--ink-3);font-size:0.86rem;">Filter pending review by source:</label>
<input id="wb-source-filter" name="source" value="{source_val}" placeholder="doi:10.1056/ or pmid:36811" style="flex:0 0 18rem;">
<button type="submit">Apply filter</button>
{clear_link}
</form>"#,
source_val = escape_html(&filter.source),
clear_link = if filter.source.trim().is_empty() {
String::new()
} else {
r#"<a href="/review/inbox" style="color:var(--ink-3);">Clear</a>"#.to_string()
},
));
body.push_str(r#"<div class="wb-stats">"#);
for (n, label) in [
(total_locator, "missing locator"),
(total_span, "missing span"),
(total_entity, "needs review"),
(total_link, "no links"),
(total_promote, "pending review"),
(total_conflicts, "federation conflicts"),
] {
body.push_str(&format!(
r#"<div><div class="wb-stat__num">{n}</div><div class="wb-stat__label">{label}</div></div>"#
));
}
body.push_str("</div>");
let cutoff_seven_days = chrono::Utc::now() - chrono::Duration::days(7);
let events_last_7d: Vec<&crate::events::StateEvent> = project
.events
.iter()
.filter(|e| {
chrono::DateTime::parse_from_rfc3339(&e.timestamp)
.map(|dt| dt.with_timezone(&chrono::Utc) >= cutoff_seven_days)
.unwrap_or(false)
})
.collect();
let total_events_7d = events_last_7d.len();
let mut kind_counts: std::collections::BTreeMap<&str, usize> =
std::collections::BTreeMap::new();
for e in &events_last_7d {
*kind_counts.entry(e.kind.as_str()).or_insert(0) += 1;
}
let mut top_kinds: Vec<(&str, usize)> = kind_counts.into_iter().collect();
top_kinds.sort_by(|a, b| b.1.cmp(&a.1).then(a.0.cmp(b.0)));
let top_kinds: Vec<(&str, usize)> = top_kinds.into_iter().take(5).collect();
let event_by_id: std::collections::HashMap<&str, &crate::events::StateEvent> =
project.events.iter().map(|e| (e.id.as_str(), e)).collect();
let mut latencies_sec: Vec<i64> = Vec::new();
let mut applied_count: usize = 0;
let mut pending_count: usize = 0;
for p in &project.proposals {
match p.status.as_str() {
"applied" => {
applied_count += 1;
let queue_start = p.drafted_at.as_deref().unwrap_or(p.created_at.as_str());
if let Some(eid) = p.applied_event_id.as_deref()
&& let Some(ev) = event_by_id.get(eid)
&& let (Ok(c), Ok(a)) = (
chrono::DateTime::parse_from_rfc3339(queue_start),
chrono::DateTime::parse_from_rfc3339(&ev.timestamp),
)
{
let secs = (a.timestamp() - c.timestamp()).max(0);
latencies_sec.push(secs);
}
}
"pending_review" => pending_count += 1,
_ => {}
}
}
latencies_sec.sort_unstable();
let median_latency_sec = if latencies_sec.is_empty() {
None
} else {
Some(latencies_sec[latencies_sec.len() / 2])
};
let median_latency_label = match median_latency_sec {
None => "n/a".to_string(),
Some(s) if s < 60 => format!("{s}s"),
Some(s) if s < 3600 => format!("{}m", s / 60),
Some(s) if s < 86400 => format!("{}h", s / 3600),
Some(s) => format!("{}d", s / 86400),
};
let total_proposals = project.proposals.len();
let applied_pct = if total_proposals == 0 {
0
} else {
(applied_count * 100) / total_proposals
};
body.push_str(r#"<div class="wb-card"><h3>Throughput, last 7 days</h3>"#);
body.push_str(&format!(
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>"#
));
if !top_kinds.is_empty() {
body.push_str(r#"<table class="wb-table"><thead><tr><th>kind</th><th>count (7d)</th></tr></thead><tbody>"#);
for (k, n) in &top_kinds {
body.push_str(&format!(
r#"<tr><td><code>{kind}</code></td><td>{n}</td></tr>"#,
kind = escape_html(k),
));
}
body.push_str("</tbody></table>");
} else {
body.push_str(
r#"<p style="color:var(--ink-3);">No canonical events in the last 7 days. Quiet frontier or fresh seed.</p>"#,
);
}
body.push_str("</div>");
let render_atom = |a: &crate::sources::EvidenceAtom| {
format!(
r#"<tr><td><code>{aid}</code></td><td><code>{fid}</code></td><td><a href="/review/locator-repair/{aid}">repair →</a></td></tr>"#,
aid = escape_html(&a.id),
fid = escape_html(&a.finding_id),
)
};
let render_finding = |f: &FindingBundle, route: &str| {
format!(
r#"<tr><td><code>{fid}</code></td><td title="{full}">{txt}</td><td><a href="/review/{route}/{fid}">repair →</a></td></tr>"#,
fid = escape_html(&f.id),
txt = escape_html(&truncate(&f.assertion.text, 80)),
full = escape_html(&f.assertion.text),
)
};
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>"#);
for a in &locator_gaps {
body.push_str(&render_atom(a));
}
body.push_str("</tbody></table></div>");
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>"#);
for f in &span_gaps {
body.push_str(&render_finding(f, "span-repair"));
}
body.push_str("</tbody></table></div>");
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>"#);
for f in &entity_gaps {
body.push_str(&render_finding(f, "entity-resolve"));
}
body.push_str("</tbody></table></div>");
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>"#);
for f in &link_gaps {
body.push_str(&format!(
r#"<tr><td><code>{fid}</code></td><td>{txt}</td></tr>"#,
fid = escape_html(&f.id),
txt = escape_html(&truncate(&f.assertion.text, 80)),
));
}
body.push_str("</tbody></table></div>");
body.push_str(r#"<div class="wb-card"><h3>Findings pending review</h3>"#);
if promote_pending.is_empty() {
body.push_str(
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>"#,
);
} else {
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>"#);
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>"#);
for f in &promote_pending {
let state_label = match &f.flags.review_state {
Some(crate::bundle::ReviewState::NeedsRevision) => "needs_revision",
None => "(unset)",
_ => "(other)",
};
let source_ref =
if let Some(doi) = f.provenance.doi.as_deref().filter(|s| !s.is_empty()) {
format!("<code>doi:{}</code>", escape_html(doi))
} else if let Some(pmid) = f.provenance.pmid.as_deref().filter(|s| !s.is_empty()) {
format!("<code>pmid:{}</code>", escape_html(pmid))
} else {
"<span style=\"color:var(--ink-3);\">none</span>".to_string()
};
let year_ref = f
.provenance
.year
.map(|y| format!(" · {y}"))
.unwrap_or_default();
body.push_str(&format!(
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>"#,
fid = escape_html(&f.id),
txt = escape_html(&truncate(&f.assertion.text, 80)),
full = escape_html(&f.assertion.text),
src = source_ref,
year = year_ref,
state = state_label,
));
}
body.push_str("</tbody></table>");
}
body.push_str("</div>");
body.push_str(r#"<div class="wb-card"><h3>Federation conflicts</h3>"#);
if federation_conflicts.is_empty() {
body.push_str(
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>"#,
);
} else {
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>"#);
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>"#);
let resolved_index: std::collections::HashSet<String> = project
.events
.iter()
.filter(|e| e.kind == "frontier.conflict_resolved")
.filter_map(|e| {
e.payload
.get("conflict_event_id")
.and_then(|v| v.as_str())
.map(str::to_string)
})
.collect();
for ev in &federation_conflicts {
let peer = ev
.payload
.get("peer_id")
.and_then(|v| v.as_str())
.unwrap_or("?")
.to_string();
let fid = ev
.payload
.get("finding_id")
.and_then(|v| v.as_str())
.unwrap_or("?")
.to_string();
let conflict_kind = ev
.payload
.get("kind")
.and_then(|v| v.as_str())
.unwrap_or("?")
.to_string();
let resolved = resolved_index.contains(&ev.id);
let (state_label, action_cell) = if resolved {
(
"<code>resolved</code>",
"<span style=\"color:var(--ink-3);\">recorded</span>".to_string(),
)
} else {
(
"<code>open</code>",
format!(
r#"<a href="/review/conflict-resolve/{cid}">resolve →</a>"#,
cid = escape_html(&ev.id),
),
)
};
body.push_str(&format!(
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>"#,
peer = escape_html(&peer),
fid = escape_html(&fid),
kind = escape_html(&conflict_kind),
ts = escape_html(&ev.timestamp[..10.min(ev.timestamp.len())]),
state = state_label,
action = action_cell,
));
}
body.push_str("</tbody></table>");
}
body.push_str("</div>");
let html = shell(
"review",
"Inbox · Vela Workbench",
"Workbench",
"Inbox",
&body,
);
Html(html).into_response()
}
async fn page_review_locator_repair(
AxumPath(atom_id): AxumPath<String>,
State(state): State<AppState>,
Query(q): Query<ErrorTokenQuery>,
) -> Response {
let project = match repo::load_from_path(&state.repo_path) {
Ok(p) => p,
Err(e) => return error_page("review", "Could not load frontier", &e),
};
let Some(atom) = project.evidence_atoms.iter().find(|a| a.id == atom_id) else {
return error_page("review", "Atom not found", &atom_id);
};
let parent_locator = project
.sources
.iter()
.find(|s| s.id == atom.source_id)
.map(|s| s.locator.clone())
.unwrap_or_default();
let cached = q
.error
.as_deref()
.and_then(|tok| take_form_state(&state, tok));
let (locator_val, reviewer_val, reason_val, banner) = match cached {
Some(FormState::LocatorRepair {
locator,
reviewer,
reason,
error,
..
}) => (locator, reviewer, reason, render_error_banner(&error)),
_ => (
parent_locator.clone(),
default_reviewer(),
"Mechanical evidence-atom locator repair from parent source.".to_string(),
String::new(),
),
};
let body = format!(
r#"{datalist}{banner}<div class="wb-card"><h3>Locator repair</h3>
<p>Atom <code>{aid}</code> on finding <code>{fid}</code>.</p>
<p>Parent source <code>{sid}</code> carries locator <code>{loc}</code>.</p>
<form method="post" action="/review/locator-repair">
<input type="hidden" name="atom_id" value="{aid_safe}">
<p><label>Locator <input name="locator" value="{loc_safe}" style="width:36rem;"></label></p>
<p><label>Reviewer <input name="reviewer" value="{rev}" list="vela-actors"></label></p>
<p><label>Reason <input name="reason" value="{reason_safe}" style="width:36rem;"></label></p>
<p><button type="submit">Apply</button> <a href="/review/inbox" style="margin-left:1rem;color:var(--ink-3);">Cancel</a></p>
</form></div>"#,
datalist = actor_datalist(&project),
banner = banner,
aid = escape_html(&atom.id),
aid_safe = escape_html(&atom.id),
fid = escape_html(&atom.finding_id),
sid = escape_html(&atom.source_id),
loc = escape_html(&parent_locator),
loc_safe = escape_html(&locator_val),
rev = escape_html(&reviewer_val),
reason_safe = escape_html(&reason_val),
);
let html = shell(
"review",
"Locator repair · Vela Workbench",
"Workbench",
"Locator repair",
&body,
);
Html(html).into_response()
}
async fn post_review_locator_repair(
State(state): State<AppState>,
Form(form): Form<LocatorRepairForm>,
) -> Response {
match state::repair_evidence_atom_locator(
&state.repo_path,
&form.atom_id,
Some(&form.locator),
&form.reviewer,
&form.reason,
true,
) {
Ok(_) => Redirect::to("/review/inbox").into_response(),
Err(e) => {
let token = store_form_state(
&state,
FormState::LocatorRepair {
atom_id: form.atom_id.clone(),
locator: form.locator.clone(),
reviewer: form.reviewer.clone(),
reason: form.reason.clone(),
error: e,
},
);
let url = format!(
"/review/locator-repair/{aid}?error={tok}",
aid = urlencode_path(&form.atom_id),
tok = token,
);
Redirect::to(&url).into_response()
}
}
}
async fn page_review_span_repair(
AxumPath(finding_id): AxumPath<String>,
State(state): State<AppState>,
Query(q): Query<ErrorTokenQuery>,
) -> Response {
let project = match repo::load_from_path(&state.repo_path) {
Ok(p) => p,
Err(e) => return error_page("review", "Could not load frontier", &e),
};
let Some(f) = project.findings.iter().find(|f| f.id == finding_id) else {
return error_page("review", "Finding not found", &finding_id);
};
let cache_text = lookup_cached_abstract(&state.repo_path, f);
let pre_text = cache_text.as_deref().unwrap_or("");
let cache_note = if cache_text.is_some() {
r#"<p style="color:var(--ink-3);font-size:0.86rem;">text pre-filled from sources/cache/</p>"#
} else {
""
};
let cached = q
.error
.as_deref()
.and_then(|tok| take_form_state(&state, tok));
let (section_val, text_val, reviewer_val, reason_val, banner) = match cached {
Some(FormState::SpanRepair {
section,
text,
reviewer,
reason,
error,
..
}) => (section, text, reviewer, reason, render_error_banner(&error)),
_ => (
"abstract".to_string(),
pre_text.to_string(),
default_reviewer(),
"Reviewer-verified evidence span.".to_string(),
String::new(),
),
};
let body = format!(
r#"{banner}<div class="wb-card"><h3>Span repair</h3>
<p>Finding <code>{fid}</code>.</p>
<p style="font-size:0.92rem;color:var(--ink-2);">{assertion}</p>
<form method="post" action="/review/span-repair">
<input type="hidden" name="finding_id" value="{fid_safe}">
<p><label>Section <input name="section" value="{section_safe}"></label></p>
<p><label>Text<br><textarea name="text" rows="6" style="width:36rem;">{text}</textarea></label></p>
{cache_note}
<p><label>Reviewer <input name="reviewer" value="{rev}" list="vela-actors"></label></p>
<p><label>Reason <input name="reason" value="{reason_safe}" style="width:36rem;"></label></p>
<p><button type="submit">Apply</button> <a href="/review/inbox" style="margin-left:1rem;color:var(--ink-3);">Cancel</a></p>
</form></div>"#,
banner = banner,
fid = escape_html(&f.id),
fid_safe = escape_html(&f.id),
assertion = escape_html(&f.assertion.text),
section_safe = escape_html(§ion_val),
text = escape_html(&text_val),
cache_note = cache_note,
rev = escape_html(&reviewer_val),
reason_safe = escape_html(&reason_val),
);
let body = format!("{}{}", actor_datalist(&project), body);
let html = shell(
"review",
"Span repair · Vela Workbench",
"Workbench",
"Span repair",
&body,
);
Html(html).into_response()
}
async fn post_review_span_repair(
State(state): State<AppState>,
Form(form): Form<SpanRepairForm>,
) -> Response {
match state::repair_finding_span(
&state.repo_path,
&form.finding_id,
&form.section,
&form.text,
&form.reviewer,
&form.reason,
true,
) {
Ok(_) => Redirect::to("/review/inbox").into_response(),
Err(e) => {
let token = store_form_state(
&state,
FormState::SpanRepair {
finding_id: form.finding_id.clone(),
section: form.section.clone(),
text: form.text.clone(),
reviewer: form.reviewer.clone(),
reason: form.reason.clone(),
error: e,
},
);
let url = format!(
"/review/span-repair/{fid}?error={tok}",
fid = urlencode_path(&form.finding_id),
tok = token,
);
Redirect::to(&url).into_response()
}
}
}
async fn page_review_entity_resolve(
AxumPath(finding_id): AxumPath<String>,
State(state): State<AppState>,
Query(q): Query<ErrorTokenQuery>,
) -> Response {
let project = match repo::load_from_path(&state.repo_path) {
Ok(p) => p,
Err(e) => return error_page("review", "Could not load frontier", &e),
};
let Some(f) = project.findings.iter().find(|f| f.id == finding_id) else {
return error_page("review", "Finding not found", &finding_id);
};
let unresolved: Vec<_> = f
.assertion
.entities
.iter()
.filter(|e| e.needs_review)
.collect();
if unresolved.is_empty() {
return error_page(
"review",
"Nothing to resolve",
"All entities on this finding are already resolved.",
);
}
let cached_entity_state = q
.error
.as_deref()
.and_then(|tok| take_form_state(&state, tok));
let cached = match cached_entity_state {
Some(FormState::EntityResolve {
entity_name,
source,
id,
confidence,
matched_name,
reviewer,
reason,
error,
..
}) => Some((
entity_name,
source,
id,
confidence,
matched_name,
reviewer,
reason,
error,
)),
_ => None,
};
let banner = cached
.as_ref()
.map(|c| render_error_banner(&c.7))
.unwrap_or_default();
let mut forms = String::new();
let source_options = |selected: &str| -> String {
let opts = [
("hgnc", "HGNC (gene)"),
("uniprot", "UniProt (protein)"),
("mesh", "MeSH (disease/concept)"),
("uberon", "UBERON (anatomy)"),
("cl", "CL (cell type)"),
("drugbank", "DrugBank (compound)"),
("vela", "vela: (custom)"),
];
let mut out = String::new();
for (val, label) in opts {
let sel = if val == selected { " selected" } else { "" };
out.push_str(&format!(r#"<option value="{val}"{sel}>{label}</option>"#));
}
out
};
for ent in &unresolved {
let (source_val, id_val, conf_val, matched_val, reviewer_val, reason_val) =
match cached.as_ref() {
Some(c) if c.0 == ent.name => (
c.1.clone(),
c.2.clone(),
c.3,
c.4.clone().unwrap_or_default(),
c.5.clone(),
c.6.clone(),
),
_ => (
"hgnc".to_string(),
String::new(),
0.95,
String::new(),
default_reviewer(),
"Resolved against canonical biological databases.".to_string(),
),
};
forms.push_str(&format!(
r#"<div class="wb-card"><h3>{name} <span class="wb-chip wb-chip--warn">{etype}</span></h3>
<form method="post" action="/review/entity-resolve">
<input type="hidden" name="finding_id" value="{fid}">
<input type="hidden" name="entity_name" value="{name_safe}">
<p><label>Source <select name="source">
{src_opts}
</select></label></p>
<p><label>ID <input name="id" value="{id_val}" placeholder="e.g. 8804 or P05067"></label></p>
<p><label>Confidence <input name="confidence" type="number" min="0" max="1" step="0.01" value="{conf_val}"></label></p>
<p><label>Matched name <input name="matched_name" value="{matched_val}" placeholder="optional"></label></p>
<input type="hidden" name="resolution_method" value="manual">
<p><label>Reviewer <input name="reviewer" value="{rev}" list="vela-actors"></label></p>
<p><label>Reason <input name="reason" value="{reason_safe}" style="width:36rem;"></label></p>
<p><button type="submit">Apply</button> <a href="/review/inbox" style="margin-left:1rem;color:var(--ink-3);">Cancel</a></p>
</form></div>"#,
name = escape_html(&ent.name),
name_safe = escape_html(&ent.name),
etype = escape_html(&ent.entity_type),
fid = escape_html(&f.id),
src_opts = source_options(&source_val),
id_val = escape_html(&id_val),
conf_val = conf_val,
matched_val = escape_html(&matched_val),
rev = escape_html(&reviewer_val),
reason_safe = escape_html(&reason_val),
));
}
let body = format!(
r#"{banner}<div class="wb-card"><h3>Entity resolution for <code>{fid}</code></h3>
<p style="font-size:0.92rem;color:var(--ink-2);">{assertion}</p>
<p>{n} unresolved entities below.</p>
</div>{forms}"#,
banner = banner,
fid = escape_html(&f.id),
assertion = escape_html(&f.assertion.text),
n = unresolved.len(),
forms = forms,
);
let body = format!("{}{}", actor_datalist(&project), body);
let html = shell(
"review",
"Entity resolve · Vela Workbench",
"Workbench",
"Entity resolve",
&body,
);
Html(html).into_response()
}
async fn post_review_entity_resolve(
State(state): State<AppState>,
Form(form): Form<EntityResolveForm>,
) -> Response {
match state::resolve_finding_entity(
&state.repo_path,
&form.finding_id,
&form.entity_name,
&form.source,
&form.id,
form.confidence,
form.matched_name.as_deref(),
&form.resolution_method,
&form.reviewer,
&form.reason,
true,
) {
Ok(_) => Redirect::to("/review/inbox").into_response(),
Err(e) => {
let token = store_form_state(
&state,
FormState::EntityResolve {
finding_id: form.finding_id.clone(),
entity_name: form.entity_name.clone(),
source: form.source.clone(),
id: form.id.clone(),
confidence: form.confidence,
matched_name: form.matched_name.clone(),
resolution_method: form.resolution_method.clone(),
reviewer: form.reviewer.clone(),
reason: form.reason.clone(),
error: e,
},
);
let url = format!(
"/review/entity-resolve/{fid}?error={tok}",
fid = urlencode_path(&form.finding_id),
tok = token,
);
Redirect::to(&url).into_response()
}
}
}
async fn page_review_promote(
AxumPath(finding_id): AxumPath<String>,
State(state): State<AppState>,
Query(q): Query<ErrorTokenQuery>,
) -> Response {
let project = match repo::load_from_path(&state.repo_path) {
Ok(p) => p,
Err(e) => return error_page("review", "Could not load frontier", &e),
};
let Some(f) = project.findings.iter().find(|f| f.id == finding_id) else {
return error_page("review", "Finding not found", &finding_id);
};
let current_state = match &f.flags.review_state {
Some(crate::bundle::ReviewState::Accepted) => "accepted",
Some(crate::bundle::ReviewState::Contested) => "contested",
Some(crate::bundle::ReviewState::NeedsRevision) => "needs_revision",
Some(crate::bundle::ReviewState::Rejected) => "rejected",
None => "(unset)",
};
let assertion = escape_html(&f.assertion.text);
let confidence = f.confidence.score;
let source_block = {
let mut parts: Vec<String> = Vec::new();
if let Some(doi) = f.provenance.doi.as_deref() {
parts.push(format!("<code>doi:{}</code>", escape_html(doi)));
}
if let Some(pmid) = f.provenance.pmid.as_deref() {
parts.push(format!("<code>pmid:{}</code>", escape_html(pmid)));
}
if let Some(y) = f.provenance.year {
parts.push(format!("{y}"));
}
if let Some(j) = f.provenance.journal.as_deref()
&& !j.is_empty()
{
parts.push(escape_html(j));
}
if parts.is_empty() {
"<span style=\"color:var(--ink-3);\">no source metadata</span>".to_string()
} else {
parts.join(" · ")
}
};
let mut spans_block = String::new();
if f.evidence.evidence_spans.is_empty() {
spans_block.push_str(
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>"#,
);
} else {
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>"#);
for s in &f.evidence.evidence_spans {
let section = s
.get("section")
.and_then(|v| v.as_str())
.unwrap_or("(unsectioned)");
let text = s.get("text").and_then(|v| v.as_str()).unwrap_or("");
if text.is_empty() {
continue;
}
spans_block.push_str(&format!(
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>"#,
section = escape_html(section),
text = escape_html(text),
));
}
}
let cached = q
.error
.as_deref()
.and_then(|tok| take_form_state(&state, tok));
let (status_val, reviewer_val, reason_val, banner) = match cached {
Some(FormState::Promote {
status,
reviewer,
reason,
error,
..
}) => (status, reviewer, reason, render_error_banner(&error)),
_ => (
"accepted".to_string(),
default_reviewer(),
String::new(),
String::new(),
),
};
let status_options = {
let opts = ["accepted", "contested", "needs_revision", "rejected"];
let mut out = String::new();
for v in opts {
let sel = if v == status_val { " selected" } else { "" };
out.push_str(&format!(r#"<option value="{v}"{sel}>{v}</option>"#));
}
out
};
let body = format!(
r#"{banner}<div class="wb-card"><h3>Promote to accepted-core</h3>
<p>Finding <code>{fid}</code> · <a href="/findings/{fid}">inspect full record →</a></p>
<p>Source: {src}</p>
<p>Current review state: <code>{current}</code> · raw confidence: <code>{conf:.2}</code></p>
<p style="font-weight:500;margin-top:0.6rem;">Assertion</p>
<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>
<p style="font-weight:500;margin-top:0.6rem;">Evidence</p>
{spans_block}
<form method="post" action="/review/promote" style="margin-top:0.6rem;">
<input type="hidden" name="finding_id" value="{fid_safe}">
<p><label>Status
<select name="status">
{status_options}
</select>
</label></p>
<p><label>Reviewer <input name="reviewer" value="{rev}" list="vela-actors" required></label></p>
<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>
<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>
<p><button type="submit">Apply</button> <a href="/review/inbox" style="margin-left:1rem;color:var(--ink-3);">Cancel</a></p>
</form></div>"#,
banner = banner,
fid = escape_html(&f.id),
fid_safe = escape_html(&f.id),
current = current_state,
conf = confidence,
assertion = assertion,
src = source_block,
spans_block = spans_block,
status_options = status_options,
rev = escape_html(&reviewer_val),
reason_safe = escape_html(&reason_val),
);
let body = format!("{}{}", actor_datalist(&project), body);
let html = shell(
"review",
"Promote to accepted-core · Vela Workbench",
"Workbench",
"Promote to accepted-core",
&body,
);
Html(html).into_response()
}
async fn post_review_promote(
State(state): State<AppState>,
Form(form): Form<PromoteForm>,
) -> Response {
let options = state::ReviewOptions {
status: form.status.clone(),
reason: form.reason.clone(),
reviewer: form.reviewer.clone(),
};
match state::review_finding(&state.repo_path, &form.finding_id, options, true) {
Ok(_) => Redirect::to("/review/inbox").into_response(),
Err(e) => {
let token = store_form_state(
&state,
FormState::Promote {
finding_id: form.finding_id.clone(),
status: form.status.clone(),
reviewer: form.reviewer.clone(),
reason: form.reason.clone(),
error: e,
},
);
let url = format!(
"/review/promote/{fid}?error={tok}",
fid = urlencode_path(&form.finding_id),
tok = token,
);
Redirect::to(&url).into_response()
}
}
}
async fn page_review_conflict_resolve(
AxumPath(conflict_event_id): AxumPath<String>,
State(state): State<AppState>,
Query(q): Query<ErrorTokenQuery>,
) -> Response {
let project = match repo::load_from_path(&state.repo_path) {
Ok(p) => p,
Err(e) => return error_page("review", "Could not load frontier", &e),
};
let Some(conflict) = project
.events
.iter()
.find(|e| e.id == conflict_event_id && e.kind == "frontier.conflict_detected")
else {
return error_page(
"review",
"Conflict event not found",
&format!(
"No `frontier.conflict_detected` event with id '{conflict_event_id}' on this frontier."
),
);
};
let already_resolved = project.events.iter().any(|e| {
e.kind == "frontier.conflict_resolved"
&& e.payload.get("conflict_event_id").and_then(|v| v.as_str())
== Some(&conflict_event_id)
});
if already_resolved {
return error_page(
"review",
"Conflict already resolved",
&format!(
"Conflict event '{conflict_event_id}' already has a recorded resolution. The resolution event lives in the log; reviewers do not amend prior verdicts."
),
);
}
let peer = conflict
.payload
.get("peer_id")
.and_then(|v| v.as_str())
.unwrap_or("?")
.to_string();
let finding_ref = conflict
.payload
.get("finding_id")
.and_then(|v| v.as_str())
.unwrap_or("?")
.to_string();
let kind = conflict
.payload
.get("kind")
.and_then(|v| v.as_str())
.unwrap_or("?")
.to_string();
let detail = conflict
.payload
.get("detail")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
let cached = q
.error
.as_deref()
.and_then(|tok| take_form_state(&state, tok));
let (note_val, winning_val, reviewer_val, banner) = match cached {
Some(FormState::ConflictResolve {
resolution_note,
winning_proposal_id,
reviewer,
error,
..
}) => (
resolution_note,
winning_proposal_id.unwrap_or_default(),
reviewer,
render_error_banner(&error),
),
_ => (
String::new(),
String::new(),
default_reviewer(),
String::new(),
),
};
let body = format!(
r#"{banner}<div class="wb-card"><h3>Resolve federation conflict</h3>
<p>Conflict event <code>{cid}</code></p>
<p>Detected by sync with peer <code>{peer}</code> on <code>{fid}</code> with kind <code>{kind}</code>.</p>
<p style="color:var(--ink-2);font-size:0.92rem;">{detail}</p>
<form method="post" action="/review/conflict-resolve">
<input type="hidden" name="conflict_event_id" value="{cid_safe}">
<p><label>Resolution note <input name="resolution_note" value="{note_safe}" placeholder="Reviewer's verdict and rationale" style="width:36rem;" required></label></p>
<p><label>Winning proposal id (optional) <input name="winning_proposal_id" value="{winning_safe}" placeholder="vpr_..." style="width:24rem;"></label></p>
<p><label>Reviewer <input name="reviewer" value="{rev}" list="vela-actors"></label></p>
<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>
<p><button type="submit">Apply</button> <a href="/review/inbox" style="margin-left:1rem;color:var(--ink-3);">Cancel</a></p>
</form></div>"#,
banner = banner,
cid = escape_html(&conflict_event_id),
cid_safe = escape_html(&conflict_event_id),
peer = escape_html(&peer),
fid = escape_html(&finding_ref),
kind = escape_html(&kind),
detail = escape_html(&detail),
note_safe = escape_html(¬e_val),
winning_safe = escape_html(&winning_val),
rev = escape_html(&reviewer_val),
);
let body = format!("{}{}", actor_datalist(&project), body);
let html = shell(
"review",
"Resolve conflict · Vela Workbench",
"Workbench",
"Resolve conflict",
&body,
);
Html(html).into_response()
}
async fn post_review_conflict_resolve(
State(state): State<AppState>,
Form(form): Form<ConflictResolveForm>,
) -> Response {
let winning_proposal_id = form
.winning_proposal_id
.as_deref()
.map(str::trim)
.filter(|s| !s.is_empty());
match state::resolve_frontier_conflict(
&state.repo_path,
&form.conflict_event_id,
&form.resolution_note,
&form.reviewer,
winning_proposal_id,
true,
) {
Ok(_) => Redirect::to("/review/inbox").into_response(),
Err(e) => {
let token = store_form_state(
&state,
FormState::ConflictResolve {
conflict_event_id: form.conflict_event_id.clone(),
resolution_note: form.resolution_note.clone(),
winning_proposal_id: form.winning_proposal_id.clone(),
reviewer: form.reviewer.clone(),
error: e,
},
);
let url = format!(
"/review/conflict-resolve/{cid}?error={tok}",
cid = urlencode_path(&form.conflict_event_id),
tok = token,
);
Redirect::to(&url).into_response()
}
}
}
async fn page_review_replication_add(
AxumPath(finding_id): AxumPath<String>,
State(state): State<AppState>,
Query(error_q): Query<ErrorTokenQuery>,
) -> Response {
let project = match repo::load_from_path(&state.repo_path) {
Ok(p) => p,
Err(e) => return error_page("review", "Could not load frontier", &e),
};
let Some(f) = project.findings.iter().find(|f| f.id == finding_id) else {
return error_page("review", "Finding not found", &finding_id);
};
let cached = error_q
.error
.as_deref()
.and_then(|tok| take_form_state(&state, tok));
let (outcome, attempted_by, conditions_text, source_title, doi, pmid, note, error_html) =
if let Some(FormState::ReplicationAdd {
outcome,
attempted_by,
conditions_text,
source_title,
doi,
pmid,
note,
error,
..
}) = cached
{
(
outcome,
attempted_by,
conditions_text,
source_title,
doi,
pmid,
note,
render_error_banner(&error),
)
} else {
(
"replicated".to_string(),
default_reviewer(),
String::new(),
String::new(),
String::new(),
String::new(),
String::new(),
String::new(),
)
};
let assertion = escape_html(&f.assertion.text);
let body = format!(
r#"{datalist}{error_html}<div class="wb-card"><h3>Add replication</h3>
<p>Finding <code>{fid}</code> · <a href="/findings/{fid}">inspect full record →</a></p>
<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>
<form method="post" action="/review/replication-add" style="margin-top:0.6rem;">
<input type="hidden" name="finding_id" value="{fid_safe}">
<p><label>Outcome
<select name="outcome">
<option value="replicated"{sel_rep}>replicated</option>
<option value="failed"{sel_fail}>failed</option>
<option value="partial"{sel_part}>partial</option>
<option value="inconclusive"{sel_inc}>inconclusive</option>
</select>
</label></p>
<p><label>Attempted by <input name="attempted_by" value="{attempted_by_safe}" list="vela-actors" required></label></p>
<p><label>Conditions <input name="conditions_text" value="{conditions_safe}" placeholder="model system, species, in vitro/vivo, dosing" style="width:36rem;" required></label></p>
<p><label>Source title <input name="source_title" value="{source_title_safe}" placeholder="Replicating paper or lab notebook" style="width:36rem;" required></label></p>
<p><label>DOI <input name="doi" value="{doi_safe}" placeholder="10.1038/..."></label> <label>PMID <input name="pmid" value="{pmid_safe}"></label></p>
<p><label>Note <input name="note" value="{note_safe}" placeholder="Reviewer note (esp. partial/inconclusive outcomes)" style="width:36rem;"></label></p>
<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>
<p><button type="submit">Apply</button> <a href="/review/inbox" style="margin-left:1rem;color:var(--ink-3);">Cancel</a></p>
</form></div>"#,
datalist = actor_datalist(&project),
error_html = error_html,
fid = escape_html(&f.id),
fid_safe = escape_html(&f.id),
assertion = assertion,
attempted_by_safe = escape_html(&attempted_by),
conditions_safe = escape_html(&conditions_text),
source_title_safe = escape_html(&source_title),
doi_safe = escape_html(&doi),
pmid_safe = escape_html(&pmid),
note_safe = escape_html(¬e),
sel_rep = if outcome == "replicated" {
" selected"
} else {
""
},
sel_fail = if outcome == "failed" { " selected" } else { "" },
sel_part = if outcome == "partial" {
" selected"
} else {
""
},
sel_inc = if outcome == "inconclusive" {
" selected"
} else {
""
},
);
let html = shell(
"review",
"Add replication · Vela Workbench",
"Workbench",
"Add replication",
&body,
);
Html(html).into_response()
}
async fn post_review_replication_add(
State(state): State<AppState>,
Form(form): Form<ReplicationAddForm>,
) -> Response {
use crate::bundle::{Conditions, Evidence, Extraction, Provenance, Replication};
let evidence = Evidence {
evidence_type: "experimental".to_string(),
model_system: String::new(),
species: None,
method: "manual".to_string(),
sample_size: None,
effect_size: None,
p_value: None,
replicated: form.outcome == "replicated",
replication_count: None,
evidence_spans: Vec::new(),
};
let lower = form.conditions_text.to_lowercase();
let conditions = Conditions {
text: form.conditions_text.clone(),
species_verified: Vec::new(),
species_unverified: Vec::new(),
in_vitro: lower.contains("in vitro"),
in_vivo: lower.contains("in vivo"),
human_data: lower.contains("human"),
clinical_trial: lower.contains("clinical trial") || lower.contains("phase"),
concentration_range: None,
duration: None,
age_group: None,
cell_type: None,
};
let provenance = Provenance {
title: form.source_title.clone(),
source_type: "lab_notebook".to_string(),
doi: if form.doi.trim().is_empty() {
None
} else {
Some(form.doi.trim().to_string())
},
pmid: if form.pmid.trim().is_empty() {
None
} else {
Some(form.pmid.trim().to_string())
},
pmc: None,
openalex_id: None,
url: None,
authors: Vec::new(),
year: None,
journal: None,
license: None,
publisher: None,
funders: Vec::new(),
extraction: Extraction::default(),
review: None,
citation_count: None,
};
let rep = Replication::new(
form.finding_id.clone(),
form.attempted_by.clone(),
form.outcome.clone(),
evidence,
conditions,
provenance,
form.note.clone(),
);
match state::deposit_replication(
&state.repo_path,
rep,
&form.attempted_by,
"Replication deposit via local Workbench",
) {
Ok(_) => Redirect::to("/review/inbox").into_response(),
Err(e) => {
let token = store_form_state(
&state,
FormState::ReplicationAdd {
finding_id: form.finding_id.clone(),
outcome: form.outcome.clone(),
attempted_by: form.attempted_by.clone(),
conditions_text: form.conditions_text.clone(),
source_title: form.source_title.clone(),
doi: form.doi.clone(),
pmid: form.pmid.clone(),
note: form.note.clone(),
error: e,
},
);
let url = format!(
"/review/replication-add/{fid}?error={tok}",
fid = urlencode_path(&form.finding_id),
tok = token,
);
Redirect::to(&url).into_response()
}
}
}
async fn page_review_prediction_add(
AxumPath(finding_id): AxumPath<String>,
State(state): State<AppState>,
Query(error_q): Query<ErrorTokenQuery>,
) -> Response {
let project = match repo::load_from_path(&state.repo_path) {
Ok(p) => p,
Err(e) => return error_page("review", "Could not load frontier", &e),
};
let Some(f) = project.findings.iter().find(|f| f.id == finding_id) else {
return error_page("review", "Finding not found", &finding_id);
};
let cached = error_q
.error
.as_deref()
.and_then(|tok| take_form_state(&state, tok));
let (
claim_text,
resolves_by,
resolution_criterion,
expected_outcome,
made_by,
confidence,
conditions_text,
error_html,
) = if let Some(FormState::PredictionAdd {
claim_text,
resolves_by,
resolution_criterion,
expected_outcome,
made_by,
confidence,
conditions_text,
error,
..
}) = cached
{
(
claim_text,
resolves_by,
resolution_criterion,
expected_outcome,
made_by,
confidence,
conditions_text,
render_error_banner(&error),
)
} else {
(
String::new(),
String::new(),
String::new(),
"affirmed".to_string(),
default_reviewer(),
0.7,
String::new(),
String::new(),
)
};
let assertion = escape_html(&f.assertion.text);
let body = format!(
r#"{datalist}{error_html}<div class="wb-card"><h3>Add prediction</h3>
<p>Finding <code>{fid}</code> · <a href="/findings/{fid}">inspect full record →</a></p>
<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>
<form method="post" action="/review/prediction-add" style="margin-top:0.6rem;">
<input type="hidden" name="finding_id" value="{fid_safe}">
<p><label>Falsifiable claim <input name="claim_text" value="{claim_safe}" placeholder="What you expect to be true" style="width:36rem;" required></label></p>
<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>
<p><label>Resolution criterion <input name="resolution_criterion" value="{rc_safe}" placeholder="We will know this resolved when..." style="width:36rem;" required></label></p>
<p><label>Expected outcome
<select name="expected_outcome">
<option value="affirmed"{sel_aff}>affirmed</option>
<option value="falsified"{sel_fal}>falsified</option>
</select>
</label></p>
<p><label>Made by <input name="made_by" value="{made_by_safe}" list="vela-actors" required></label></p>
<p><label>Prior belief (0..1) <input name="confidence" type="number" step="0.01" min="0" max="1" value="{conf:.2}" required></label></p>
<p><label>Conditions <input name="conditions_text" value="{cond_safe}" placeholder="When this prediction applies" style="width:36rem;"></label></p>
<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>
<p><button type="submit">Apply</button> <a href="/review/inbox" style="margin-left:1rem;color:var(--ink-3);">Cancel</a></p>
</form></div>"#,
datalist = actor_datalist(&project),
error_html = error_html,
fid = escape_html(&f.id),
fid_safe = escape_html(&f.id),
assertion = assertion,
claim_safe = escape_html(&claim_text),
rb_safe = escape_html(&resolves_by),
rc_safe = escape_html(&resolution_criterion),
made_by_safe = escape_html(&made_by),
conf = confidence,
cond_safe = escape_html(&conditions_text),
sel_aff = if expected_outcome == "affirmed" {
" selected"
} else {
""
},
sel_fal = if expected_outcome == "falsified" {
" selected"
} else {
""
},
);
let html = shell(
"review",
"Add prediction · Vela Workbench",
"Workbench",
"Add prediction",
&body,
);
Html(html).into_response()
}
async fn post_review_prediction_add(
State(state): State<AppState>,
Form(form): Form<PredictionAddForm>,
) -> Response {
use crate::bundle::{Conditions, ExpectedOutcome, Prediction};
use chrono::Utc;
let lower = form.conditions_text.to_lowercase();
let conditions = Conditions {
text: form.conditions_text.clone(),
species_verified: Vec::new(),
species_unverified: Vec::new(),
in_vitro: lower.contains("in vitro"),
in_vivo: lower.contains("in vivo"),
human_data: lower.contains("human"),
clinical_trial: lower.contains("clinical trial") || lower.contains("phase"),
concentration_range: None,
duration: None,
age_group: None,
cell_type: None,
};
let expected = match form.expected_outcome.as_str() {
"affirmed" => ExpectedOutcome::Affirmed,
"falsified" => ExpectedOutcome::Falsified,
_ => ExpectedOutcome::Affirmed,
};
let resolves_by = if form.resolves_by.trim().is_empty() {
None
} else {
Some(form.resolves_by.trim().to_string())
};
let predicted_at = Utc::now().to_rfc3339();
let pred = Prediction::new(
form.claim_text.clone(),
vec![form.finding_id.clone()],
Some(predicted_at),
resolves_by,
form.resolution_criterion.clone(),
expected,
form.made_by.clone(),
form.confidence,
conditions,
);
match state::deposit_prediction(
&state.repo_path,
pred,
&form.made_by,
"Prediction deposit via local Workbench",
) {
Ok(_) => Redirect::to("/review/inbox").into_response(),
Err(e) => {
let token = store_form_state(
&state,
FormState::PredictionAdd {
finding_id: form.finding_id.clone(),
claim_text: form.claim_text.clone(),
resolves_by: form.resolves_by.clone(),
resolution_criterion: form.resolution_criterion.clone(),
expected_outcome: form.expected_outcome.clone(),
made_by: form.made_by.clone(),
confidence: form.confidence,
conditions_text: form.conditions_text.clone(),
error: e,
},
);
let url = format!(
"/review/prediction-add/{fid}?error={tok}",
fid = urlencode_path(&form.finding_id),
tok = token,
);
Redirect::to(&url).into_response()
}
}
}
fn truncate(s: &str, n: usize) -> String {
if s.chars().count() <= n {
return s.to_string();
}
let mut out: String = s.chars().take(n).collect();
out.push('…');
out
}
fn lookup_cached_abstract(repo_path: &Path, finding: &FindingBundle) -> Option<String> {
use sha2::{Digest, Sha256};
let candidates = [
finding.provenance.doi.as_ref().map(|d| format!("doi:{d}")),
finding
.provenance
.pmid
.as_ref()
.map(|p| format!("pmid:{p}")),
];
for opt in candidates.iter().flatten() {
let hash = format!("{:x}", Sha256::digest(opt.as_bytes()));
let p = repo_path
.join("sources")
.join("cache")
.join(format!("{hash}.json"));
if !p.is_file() {
continue;
}
if let Ok(body) = std::fs::read_to_string(&p)
&& let Ok(value) = serde_json::from_str::<serde_json::Value>(&body)
&& let Some(abstract_text) = value.get("abstract").and_then(|v| v.as_str())
&& !abstract_text.is_empty()
{
return Some(abstract_text.to_string());
}
}
None
}