Skip to main content

vela_protocol/
workbench.rs

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