Skip to main content

lex_api/
handlers.rs

1//! Request routing for the agent API.
2//!
3//! Each handler is a synchronous function that returns
4//! `Result<serde_json::Value, ApiError>`. The dispatcher wraps the result
5//! in an HTTP response — successes as 200 with the JSON body, structured
6//! errors as 4xx/5xx with a JSON envelope.
7
8use indexmap::IndexMap;
9use lex_ast::canonicalize_program;
10use lex_bytecode::{compile_program, vm::Vm, Value};
11use lex_runtime::{check_program as check_policy, DefaultHandler, Policy};
12use lex_store::Store;
13use lex_syntax::{load_program, load_program_from_str, Manifest};
14use lex_vcs::{MergeSession, MergeSessionId};
15use serde::{Deserialize, Serialize};
16use std::collections::{BTreeMap, BTreeSet, HashMap};
17use std::path::PathBuf;
18use std::sync::{Arc, Mutex};
19use std::time::{SystemTime, UNIX_EPOCH};
20use tiny_http::{Header, Method, Request, Response};
21
22pub struct State {
23    pub store: Mutex<Store>,
24    /// Filesystem root of the store. Held alongside the `Store`
25    /// itself so handlers that need to read store-level files
26    /// (e.g. `users.json` for actor auth) don't have to round-
27    /// trip through the lock.
28    pub root: PathBuf,
29    /// In-memory merge sessions, keyed by MergeSessionId. Sessions
30    /// are ephemeral by design (#134 foundation): they live for the
31    /// lifetime of the server process and are GC'd on commit. A
32    /// future slice can persist them to disk so a session survives
33    /// process restarts. For now an agent that gets unlucky with a
34    /// restart re-runs `merge/start` and gets a fresh session.
35    pub sessions: Mutex<HashMap<MergeSessionId, ApiMergeSession>>,
36    /// Optional server-imposed ceiling on the effect policy honored
37    /// by `/v1/run` and `/v1/replay`. `None` (the default, used by
38    /// single-tenant `lex serve`) runs the caller's request policy
39    /// as-is — the operator *is* the caller there, so that's
40    /// intended. When `Some`, the request policy is clamped via
41    /// [`clamp_policy`] so it can only *narrow* the ceiling, never
42    /// widen it.
43    ///
44    /// Any embedder that exposes this API to untrusted callers — a
45    /// hosted, multi-tenant gateway like lex-hub — MUST set this.
46    /// Without it the request body can grant itself `[proc]`
47    /// (arbitrary subprocess spawn), `[fs_*]` over `/`, and
48    /// unrestricted `[net]`: arbitrary code execution as the server
49    /// process. See lex-hub#6.
50    ///
51    /// NOTE: an empty scope list means "any path/host" in the
52    /// runtime, so a ceiling that puts `fs_read`/`fs_write`/`net` in
53    /// `allow_effects` MUST also populate the matching scope list
54    /// (`allow_fs_read`, …) or it re-opens the wildcard. Granting
55    /// none of those kinds is the safe default.
56    pub policy_ceiling: Option<Policy>,
57}
58
59/// Server-side wrapper around [`MergeSession`] carrying the
60/// branch names that started the merge. The lex-vcs session
61/// itself only tracks `OpId` heads; commit needs the dst branch
62/// name to advance the right head, and the src branch name is
63/// kept for round-trip auditability ("which branch did we merge
64/// from?").
65pub struct ApiMergeSession {
66    pub inner: MergeSession,
67    pub src_branch: String,
68    pub dst_branch: String,
69}
70
71impl State {
72    pub fn open(root: PathBuf) -> anyhow::Result<Self> {
73        Self::open_with_ceiling(root, None)
74    }
75
76    /// Like [`State::open`] but installs a [`policy_ceiling`](State::policy_ceiling)
77    /// that `/v1/run` and `/v1/replay` clamp the caller's request
78    /// policy against. Embedders exposing this API to untrusted
79    /// callers must use this constructor (or set the field directly).
80    pub fn open_with_ceiling(
81        root: PathBuf,
82        policy_ceiling: Option<Policy>,
83    ) -> anyhow::Result<Self> {
84        Ok(Self {
85            store: Mutex::new(Store::open(&root)?),
86            root,
87            sessions: Mutex::new(HashMap::new()),
88            policy_ceiling,
89        })
90    }
91
92    /// Construct a per-tenant `State` by prefixing `store_root` with the
93    /// tenant id. Single-tenant `lex serve` is unaffected — it calls
94    /// `State::open` directly.
95    ///
96    /// `tenant_id` is restricted to `[A-Za-z0-9_-]{1,64}`: anything else
97    /// (path separators, `..`, NUL, absolute paths, dotfiles, empty
98    /// string) is rejected before touching the filesystem. Without this
99    /// `PathBuf::join("/etc")` would silently replace `store_root`, and
100    /// `PathBuf::join("../foo")` would escape the tenant root.
101    pub fn new_with_tenant(tenant_id: &str, store_root: PathBuf) -> anyhow::Result<Self> {
102        validate_tenant_id(tenant_id)?;
103        Self::open(store_root.join(tenant_id))
104    }
105
106    /// Multi-tenant constructor that also installs a policy ceiling
107    /// for `/v1/run` / `/v1/replay`. The path-traversal guard from
108    /// [`new_with_tenant`](State::new_with_tenant) and the effect
109    /// ceiling are the two halves a hosted gateway needs.
110    pub fn new_with_tenant_and_ceiling(
111        tenant_id: &str,
112        store_root: PathBuf,
113        policy_ceiling: Option<Policy>,
114    ) -> anyhow::Result<Self> {
115        validate_tenant_id(tenant_id)?;
116        Self::open_with_ceiling(store_root.join(tenant_id), policy_ceiling)
117    }
118}
119
120/// Clamp a caller-supplied [`Policy`] to a server-imposed `ceiling`
121/// so it can only *narrow* the granted capabilities, never widen
122/// them. Used by [`run_handler`] when [`State::policy_ceiling`] is
123/// set — i.e. when an embedder exposes `/v1/run` to untrusted
124/// callers and must not let the request body grant itself `[proc]`,
125/// arbitrary `[fs_*]` paths, or unrestricted `[net]`.
126///
127/// - **Effects**: set-intersection of request and ceiling. The
128///   caller may drop effects but never add one the ceiling withheld.
129/// - **Scopes** (fs paths, proc binaries, net hosts): taken from the
130///   ceiling outright. The caller cannot widen them, and — because an
131///   empty scope list means "any" in the runtime — we must not let a
132///   caller's empty list collapse the ceiling's restriction back to a
133///   wildcard.
134/// - **Budget**: the more restrictive (smaller) of the two.
135fn clamp_policy(requested: Policy, ceiling: &Policy) -> Policy {
136    let allow_effects: BTreeSet<String> = requested
137        .allow_effects
138        .intersection(&ceiling.allow_effects)
139        .cloned()
140        .collect();
141    let budget = match (requested.budget, ceiling.budget) {
142        (Some(r), Some(c)) => Some(r.min(c)),
143        (None, Some(c)) => Some(c),
144        (Some(r), None) => Some(r),
145        (None, None) => None,
146    };
147    Policy {
148        allow_effects,
149        allow_fs_read: ceiling.allow_fs_read.clone(),
150        allow_fs_write: ceiling.allow_fs_write.clone(),
151        allow_net_host: ceiling.allow_net_host.clone(),
152        allow_proc: ceiling.allow_proc.clone(),
153        allow_approval: ceiling.allow_approval.clone(),
154        budget,
155    }
156}
157
158fn validate_tenant_id(tenant_id: &str) -> anyhow::Result<()> {
159    if tenant_id.is_empty() {
160        anyhow::bail!("tenant_id must not be empty");
161    }
162    if tenant_id.len() > 64 {
163        anyhow::bail!("tenant_id must be at most 64 bytes");
164    }
165    if !tenant_id
166        .bytes()
167        .all(|b| b.is_ascii_alphanumeric() || b == b'_' || b == b'-')
168    {
169        anyhow::bail!(
170            "tenant_id {tenant_id:?} contains characters outside [A-Za-z0-9_-]"
171        );
172    }
173    Ok(())
174}
175
176#[derive(Debug, Serialize, Deserialize)]
177struct ErrorEnvelope {
178    error: String,
179    #[serde(skip_serializing_if = "Option::is_none")]
180    detail: Option<serde_json::Value>,
181}
182
183fn json_response(status: u16, body: &serde_json::Value) -> Response<std::io::Cursor<Vec<u8>>> {
184    let bytes = serde_json::to_vec(body).unwrap_or_else(|_| b"{}".to_vec());
185    Response::from_data(bytes)
186        .with_status_code(status)
187        .with_header(Header::from_bytes(&b"Content-Type"[..], &b"application/json"[..]).unwrap())
188}
189
190fn error_response(status: u16, msg: impl Into<String>) -> Response<std::io::Cursor<Vec<u8>>> {
191    json_response(status, &serde_json::to_value(ErrorEnvelope {
192        error: msg.into(), detail: None,
193    }).unwrap())
194}
195
196fn error_with_detail(status: u16, msg: impl Into<String>, detail: serde_json::Value)
197    -> Response<std::io::Cursor<Vec<u8>>>
198{
199    json_response(status, &serde_json::to_value(ErrorEnvelope {
200        error: msg.into(), detail: Some(detail),
201    }).unwrap())
202}
203
204/// Map a `StoreError` from a write path (`apply_operation` /
205/// `apply_operation_checked`) to an HTTP response. The only special
206/// case today is `Contention` (#262 multi-writer CAS retries
207/// exhausted), which maps to 503 with a `Retry-After` header so
208/// clients back off rather than hammering the same branch tip.
209fn write_error_response(prefix: &str, err: lex_store::StoreError)
210    -> Response<std::io::Cursor<Vec<u8>>>
211{
212    if let lex_store::StoreError::Contention { branch, attempts } = &err {
213        let body = serde_json::to_vec(&ErrorEnvelope {
214            error: format!("{prefix}: branch '{branch}' is contended (attempts={attempts})"),
215            detail: Some(serde_json::json!({
216                "kind": "contention",
217                "branch": branch,
218                "attempts": attempts,
219            })),
220        }).unwrap_or_else(|_| b"{}".to_vec());
221        return Response::from_data(body)
222            .with_status_code(503)
223            .with_header(Header::from_bytes(&b"Content-Type"[..], &b"application/json"[..]).unwrap())
224            .with_header(Header::from_bytes(&b"Retry-After"[..], &b"1"[..]).unwrap());
225    }
226    // #292 slice 3: budget overflow → 503 with `Retry-After: 0`.
227    // Unlike Contention (where a retry might land after another
228    // writer finishes), there's no point retrying a budget-
229    // exceeded op — the caller needs to raise the cap, switch
230    // sessions, or refactor the work. The `Retry-After: 0`
231    // signals "don't bother retrying as-is" while still using
232    // the canonical "service refused" status code.
233    if let lex_store::StoreError::BudgetExceeded { session_id, cap, spent_after } = &err {
234        let body = serde_json::to_vec(&ErrorEnvelope {
235            error: format!(
236                "{prefix}: session `{session_id}` budget exceeded \
237                 (spent_after={spent_after}, cap={cap})"
238            ),
239            detail: Some(serde_json::json!({
240                "kind": "budget_exceeded",
241                "session_id": session_id,
242                "cap": cap,
243                "spent_after": spent_after,
244            })),
245        }).unwrap_or_else(|_| b"{}".to_vec());
246        return Response::from_data(body)
247            .with_status_code(503)
248            .with_header(Header::from_bytes(&b"Content-Type"[..], &b"application/json"[..]).unwrap())
249            .with_header(Header::from_bytes(&b"Retry-After"[..], &b"0"[..]).unwrap());
250    }
251    error_response(500, format!("{prefix}: {err}"))
252}
253
254pub fn handle(state: Arc<State>, mut req: Request) -> std::io::Result<()> {
255    let method = req.method().clone();
256    let url = req.url().to_string();
257    let path = url.split('?').next().unwrap_or("").to_string();
258    let query = url.split_once('?').map(|(_, q)| q.to_string()).unwrap_or_default();
259
260    // `X-Lex-User` is the v3d session identifier — set by humans
261    // operating the web UI through whatever proxy fronts auth, or
262    // by AI agents calling the JSON API. We pluck it once here so
263    // every handler can take it as a borrowed string.
264    let x_lex_user = req.headers().iter()
265        .find(|h| h.field.equiv("x-lex-user"))
266        .map(|h| h.value.as_str().to_string());
267
268    // POST /v1/pkg/publish sends a raw tar.gz body — read bytes before routing.
269    if matches!(method, Method::Post) && path == "/v1/pkg/publish" {
270        let mut body_bytes: Vec<u8> = Vec::new();
271        let _ = req.as_reader().read_to_end(&mut body_bytes);
272        let resp = pkg_publish_handler(&state, &body_bytes);
273        return req.respond(resp);
274    }
275
276    let mut body = String::new();
277    let _ = req.as_reader().read_to_string(&mut body);
278
279    let resp = route(&state, &method, &path, &query, &body, x_lex_user.as_deref());
280    req.respond(resp)
281}
282
283/// Auth-gated entry point. Calls `auth(path, headers)` before routing;
284/// returns 401 JSON when it returns false. Keeps auth logic out of the
285/// product-agnostic core.
286pub fn handle_with_auth<F>(state: Arc<State>, req: Request, auth: F) -> std::io::Result<()>
287where
288    F: FnOnce(&str, &[Header]) -> bool,
289{
290    let path = req.url().split('?').next().unwrap_or("").to_string();
291    if !auth(&path, req.headers()) {
292        return req.respond(
293            Response::from_data(br#"{"error":"unauthorized"}"#.to_vec())
294                .with_status_code(401)
295                .with_header(
296                    Header::from_bytes(&b"Content-Type"[..], &b"application/json"[..]).unwrap(),
297                ),
298        );
299    }
300    handle(state, req)
301}
302
303fn route(
304    state: &State,
305    method: &Method,
306    path: &str,
307    query: &str,
308    body: &str,
309    x_lex_user: Option<&str>,
310) -> Response<std::io::Cursor<Vec<u8>>> {
311    match (method, path) {
312        // ---- lex-tea v2 (HTML browser) ------------------------
313        (Method::Get, "/") => crate::web::activity_handler(state),
314        (Method::Get, "/web/branches") => crate::web::branches_handler(state),
315        (Method::Get, "/web/trust") => crate::web::trust_handler(state),
316        (Method::Get, "/web/attention") => crate::web::attention_handler(state),
317        (Method::Get, p) if p.starts_with("/web/branch/") => {
318            let name = &p["/web/branch/".len()..];
319            crate::web::branch_handler(state, name)
320        }
321        (Method::Get, p) if p.starts_with("/web/stage/") => {
322            let id = &p["/web/stage/".len()..];
323            crate::web::stage_html_handler(state, id)
324        }
325        // lex-tea v3 human-triage actions (#172). HTML forms post
326        // to /web/stage/<id>/{pin,defer,block,unblock} with a
327        // `reason` body. All four share one handler; the verb in
328        // the path picks the AttestationKind.
329        (Method::Post, p) if p.starts_with("/web/stage/") && (
330            p.ends_with("/pin") || p.ends_with("/defer")
331            || p.ends_with("/block") || p.ends_with("/unblock")
332        ) => {
333            let prefix_len = "/web/stage/".len();
334            let last_slash = p.rfind('/').unwrap_or(p.len());
335            let id = &p[prefix_len..last_slash];
336            let verb = &p[last_slash + 1..];
337            let decision = match verb {
338                "pin"     => crate::web::WebStageDecision::Pin,
339                "defer"   => crate::web::WebStageDecision::Defer,
340                "block"   => crate::web::WebStageDecision::Block,
341                "unblock" => crate::web::WebStageDecision::Unblock,
342                _ => unreachable!("matched in outer guard"),
343            };
344            crate::web::stage_decision_handler(state, id, body, decision, x_lex_user)
345        }
346        // ---- JSON API -----------------------------------------
347        (Method::Get, "/v1/health") => json_response(200, &serde_json::json!({"ok": true})),
348        (Method::Post, "/v1/parse") => parse_handler(body),
349        (Method::Post, "/v1/check") => check_handler(body),
350        (Method::Post, "/v1/publish") => publish_handler(state, body),
351        (Method::Post, "/v1/patch") => patch_handler(state, body),
352        (Method::Get, p) if p.starts_with("/v1/stage/") => {
353            let suffix = &p["/v1/stage/".len()..];
354            // Match `/v1/stage/<id>/attestations` first so a literal
355            // stage_id of "attestations" can't be misrouted.
356            if let Some(id) = suffix.strip_suffix("/attestations") {
357                stage_attestations_handler(state, id)
358            } else {
359                stage_handler(state, suffix)
360            }
361        }
362        (Method::Post, "/v1/run") => run_handler(state, body, false),
363        (Method::Post, "/v1/replay") => run_handler(state, body, true),
364        (Method::Get, p) if p.starts_with("/v1/trace/") => {
365            let id = &p["/v1/trace/".len()..];
366            trace_handler(state, id)
367        }
368        (Method::Get, "/v1/diff") => diff_handler(state, query),
369        (Method::Post, "/v1/merge/start") => merge_start_handler(state, body),
370        (Method::Post, p) if p.starts_with("/v1/merge/") && p.ends_with("/resolve") => {
371            let id = &p["/v1/merge/".len()..p.len() - "/resolve".len()];
372            merge_resolve_handler(state, id, body)
373        }
374        (Method::Post, p) if p.starts_with("/v1/merge/") && p.ends_with("/commit") => {
375            let id = &p["/v1/merge/".len()..p.len() - "/commit".len()];
376            merge_commit_handler(state, id)
377        }
378        // ---- #242: append-only sync of op log + attestation log
379        (Method::Post, "/v1/ops/batch") => ops_batch_handler(state, body),
380        (Method::Post, "/v1/attestations/batch") => attestations_batch_handler(state, body),
381        // Probe endpoint for `lex op push` to discover the remote's
382        // current head before computing a delta. Returns
383        // `{ "head_op": Option<OpId> }`. `<branch>` is URL-encoded.
384        (Method::Get, p) if p.starts_with("/v1/branches/") && p.ends_with("/head") => {
385            let name = &p["/v1/branches/".len()..p.len() - "/head".len()];
386            branch_head_handler(state, name)
387        }
388        // ---- #260: append-only fetch (inverse of #242 push)
389        // Body is a JSON array of OperationRecords reachable from
390        // `branch.head_op` but not from `after`, oldest-first.
391        (Method::Get, "/v1/ops/since") => ops_since_handler(state, query),
392        (Method::Get, "/v1/attestations/since") => attestations_since_handler(state, query),
393        // ---- #4: package concept ----------------------------------
394        // POST /v1/pkg/publish is handled in handle() before route()
395        // (binary body), so it doesn't appear here.
396        (Method::Get, "/v1/pkg") => pkg_list_handler(state),
397        // Owner-only visibility toggle (authed via the front door). Must
398        // precede the generic `/v1/pkg/{name}` arms; it's a PUT, so it
399        // can't collide with the GET/DELETE arms regardless.
400        (Method::Put, p) if p.starts_with("/v1/pkg/") && p.ends_with("/visibility") => {
401            let name = &p["/v1/pkg/".len()..p.len() - "/visibility".len()];
402            pkg_set_visibility_handler(state, name, body)
403        }
404        (Method::Get, p) if p.starts_with("/v1/pkg/") && p.ends_with("/head") => {
405            let name = &p["/v1/pkg/".len()..p.len() - "/head".len()];
406            pkg_head_handler(state, name)
407        }
408        (Method::Get, p) if p.starts_with("/v1/pkg/") && p.ends_with("/versions") => {
409            let name = &p["/v1/pkg/".len()..p.len() - "/versions".len()];
410            pkg_versions_handler(state, name)
411        }
412        // /v1/pkg/{name}/{version}/archive — must match before the generic /{name}/{version}
413        (Method::Get, p) if p.starts_with("/v1/pkg/") && p.ends_with("/archive") => {
414            let inner = &p["/v1/pkg/".len()..p.len() - "/archive".len()];
415            // inner = "{name}/{version}"
416            if let Some((name, version)) = inner.split_once('/') {
417                pkg_archive_handler(state, name, version)
418            } else {
419                error_response(400, "expected /v1/pkg/{name}/{version}/archive")
420            }
421        }
422        // /v1/pkg/{name}/{version}
423        (Method::Get, p) if p.starts_with("/v1/pkg/") && p["/v1/pkg/".len()..].contains('/') => {
424            let inner = &p["/v1/pkg/".len()..];
425            if let Some((name, version)) = inner.split_once('/') {
426                pkg_get_version_handler(state, name, version)
427            } else {
428                error_response(400, "expected /v1/pkg/{name}/{version}")
429            }
430        }
431        (Method::Get, p) if p.starts_with("/v1/pkg/") => {
432            let name = &p["/v1/pkg/".len()..];
433            pkg_get_handler(state, name)
434        }
435        (Method::Delete, p) if p.starts_with("/v1/pkg/") => {
436            let name = &p["/v1/pkg/".len()..];
437            pkg_delete_handler(state, name)
438        }
439        _ => error_response(404, format!("unknown route: {method:?} {path}")),
440    }
441}
442
443#[derive(Deserialize)]
444struct ParseReq { source: String }
445
446fn parse_handler(body: &str) -> Response<std::io::Cursor<Vec<u8>>> {
447    let req: ParseReq = match serde_json::from_str(body) {
448        Ok(r) => r, Err(e) => return error_response(400, format!("bad request: {e}")),
449    };
450    match load_program_from_str(&req.source) {
451        Ok(prog) => {
452            let stages = canonicalize_program(&prog);
453            json_response(200, &serde_json::to_value(&stages).unwrap())
454        }
455        Err(e) => error_response(400, format!("syntax error: {e}")),
456    }
457}
458
459pub(crate) fn check_handler(body: &str) -> Response<std::io::Cursor<Vec<u8>>> {
460    let req: ParseReq = match serde_json::from_str(body) {
461        Ok(r) => r, Err(e) => return error_response(400, format!("bad request: {e}")),
462    };
463    let prog = match load_program_from_str(&req.source) {
464        Ok(p) => p, Err(e) => return error_response(400, format!("syntax error: {e}")),
465    };
466    let stages = canonicalize_program(&prog);
467    match lex_types::check_program(&stages) {
468        Ok(_) => json_response(200, &serde_json::json!({"ok": true})),
469        Err(errs) => json_response(422, &serde_json::to_value(&errs).unwrap()),
470    }
471}
472
473#[derive(Deserialize)]
474struct PublishReq { source: String, #[serde(default)] activate: bool }
475
476pub(crate) fn publish_handler(state: &State, body: &str) -> Response<std::io::Cursor<Vec<u8>>> {
477    let req: PublishReq = match serde_json::from_str(body) {
478        Ok(r) => r, Err(e) => return error_response(400, format!("bad request: {e}")),
479    };
480    let prog = match load_program_from_str(&req.source) {
481        Ok(p) => p, Err(e) => return error_response(400, format!("syntax error: {e}")),
482    };
483    // #168: rewrite stdlib parse calls to parse_strict so the
484    // bytecode emitted from these stages enforces required-field
485    // checks at runtime.
486    let mut stages = canonicalize_program(&prog);
487    if let Err(errs) = lex_types::check_and_rewrite_program(&mut stages) {
488        return error_with_detail(422, "type errors", serde_json::to_value(&errs).unwrap());
489    }
490
491    let store = state.store.lock().unwrap();
492    let branch = store.current_branch();
493
494    // Compute diff between what's already on the branch and the new program.
495    let old_head = match store.branch_head(&branch) {
496        Ok(h) => h,
497        Err(e) => return error_response(500, format!("branch_head: {e}")),
498    };
499    let old_fns: std::collections::BTreeMap<String, lex_ast::FnDecl> = old_head.values()
500        .filter_map(|stg| store.get_ast(stg).ok())
501        .filter_map(|s| match s {
502            lex_ast::Stage::FnDecl(fd) => Some((fd.name.clone(), fd)),
503            _ => None,
504        })
505        .collect();
506    let new_fns: std::collections::BTreeMap<String, lex_ast::FnDecl> = stages.iter()
507        .filter_map(|s| match s {
508            lex_ast::Stage::FnDecl(fd) => Some((fd.name.clone(), fd.clone())),
509            _ => None,
510        })
511        .collect();
512    let report = lex_vcs::compute_diff(&old_fns, &new_fns, false);
513
514    // Build new imports map from any Import stages in the source.
515    let mut new_imports: lex_vcs::ImportMap = lex_vcs::ImportMap::new();
516    {
517        let entry = new_imports.entry("<source>".into()).or_default();
518        for s in &stages {
519            if let lex_ast::Stage::Import(im) = s {
520                entry.insert(im.reference.clone());
521            }
522        }
523    }
524
525    match store.publish_program(&branch, &stages, &report, &new_imports, req.activate) {
526        Ok(outcome) => json_response(200, &serde_json::json!({
527            "ops": outcome.ops,
528            "head_op": outcome.head_op,
529        })),
530        // The store-write gate (#130) also type-checks at the top
531        // of `publish_program`. The handler above already pre-checks,
532        // so this branch is reached only on a race or a state we
533        // didn't see at handler time. Surface the structured
534        // envelope (422) instead of a generic 500 — same shape the
535        // initial pre-check uses, so a client only has one error
536        // contract to handle.
537        Err(lex_store::StoreError::TypeError(errs)) => {
538            error_with_detail(422, "type errors", serde_json::to_value(&errs).unwrap())
539        }
540        Err(e) => write_error_response("publish_program", e),
541    }
542}
543
544#[derive(Deserialize)]
545struct PatchReq {
546    stage_id: String,
547    patch: lex_ast::Patch,
548    #[serde(default)] activate: bool,
549}
550
551/// POST /v1/patch — apply a structured edit to a stored stage's
552/// canonical AST, type-check the result, and publish a new stage.
553fn patch_handler(state: &State, body: &str) -> Response<std::io::Cursor<Vec<u8>>> {
554    let req: PatchReq = match serde_json::from_str(body) {
555        Ok(r) => r, Err(e) => return error_response(400, format!("bad request: {e}")),
556    };
557    let store = state.store.lock().unwrap();
558
559    // 1. Load.
560    let original = match store.get_ast(&req.stage_id) {
561        Ok(s) => s, Err(e) => return error_response(404, format!("stage: {e}")),
562    };
563
564    // 2. Apply.
565    let patched = match lex_ast::apply_patch(&original, &req.patch) {
566        Ok(s) => s,
567        Err(e) => return error_with_detail(422, "patch failed",
568            serde_json::to_value(&e).unwrap_or_default()),
569    };
570
571    // 3. Type-check the new stage in isolation.
572    let stages = vec![patched.clone()];
573    if let Err(errs) = lex_types::check_program(&stages) {
574        return error_with_detail(422, "type errors after patch",
575            serde_json::to_value(&errs).unwrap_or_default());
576    }
577
578    // Routing through apply_operation so /v1/patch participates in
579    // the op DAG. We know this op is always a body change on the
580    // existing sig (a patch can't add a brand-new fn).
581    let branch = store.current_branch();
582
583    // Find the sig — patched stage's sig must match the original's.
584    let sig = match lex_ast::sig_id(&patched) {
585        Some(s) => s,
586        None => return error_response(500, "patched stage has no sig_id"),
587    };
588
589    let new_id = match store.publish(&patched) {
590        Ok(id) => id, Err(e) => return error_response(500, format!("publish: {e}")),
591    };
592    if req.activate {
593        if let Err(e) = store.activate(&new_id) {
594            return error_response(500, format!("activate: {e}"));
595        }
596    }
597
598    // Determine op kind: ChangeEffectSig if effects differ, ModifyBody otherwise.
599    let original_effects: std::collections::BTreeSet<String> = match &original {
600        lex_ast::Stage::FnDecl(fd) => fd.effects.iter().map(|e| e.name.clone()).collect(),
601        _ => std::collections::BTreeSet::new(),
602    };
603    let patched_effects: std::collections::BTreeSet<String> = match &patched {
604        lex_ast::Stage::FnDecl(fd) => fd.effects.iter().map(|e| e.name.clone()).collect(),
605        _ => std::collections::BTreeSet::new(),
606    };
607    let head_now = match store.get_branch(&branch) {
608        Ok(b) => b.and_then(|b| b.head_op),
609        Err(e) => return error_response(500, format!("get_branch: {e}")),
610    };
611    let kind = if original_effects != patched_effects {
612        // #247: budget delta is part of the canonical payload now.
613        // Patch endpoints don't currently rehydrate the AST to
614        // recompute budgets, so leave them None — clients that
615        // need budget tracking should publish through the diff
616        // pipeline (`lex publish`) where `compute_diff` populates
617        // them.
618        let from_budget = lex_vcs::operation_budget_from_effects(&original_effects);
619        let to_budget = lex_vcs::operation_budget_from_effects(&patched_effects);
620        lex_vcs::OperationKind::ChangeEffectSig {
621            sig_id: sig.clone(),
622            from_stage_id: req.stage_id.clone(),
623            to_stage_id: new_id.clone(),
624            from_effects: original_effects,
625            to_effects: patched_effects,
626            from_budget,
627            to_budget,
628        }
629    } else {
630        let budget = lex_vcs::operation_budget_from_effects(&original_effects);
631        lex_vcs::OperationKind::ModifyBody {
632            sig_id: sig.clone(),
633            from_stage_id: req.stage_id.clone(),
634            to_stage_id: new_id.clone(),
635            from_budget: budget,
636            to_budget: budget,
637        }
638    };
639    let transition = lex_vcs::StageTransition::Replace {
640        sig_id: sig.clone(),
641        from: req.stage_id.clone(),
642        to: new_id.clone(),
643    };
644    let op = lex_vcs::Operation::new(
645        kind,
646        head_now.into_iter().collect::<Vec<_>>(),
647    );
648    let op_id = match store.apply_operation(&branch, op, transition) {
649        Ok(id) => id,
650        Err(e) => return write_error_response("apply_operation", e),
651    };
652
653    let status = format!("{:?}",
654        store.get_status(&new_id).unwrap_or(lex_store::StageStatus::Draft)).to_lowercase();
655    json_response(200, &serde_json::json!({
656        "old_stage_id": req.stage_id,
657        "new_stage_id": new_id,
658        "sig_id": sig,
659        "status": status,
660        "op_id": op_id,
661    }))
662}
663
664pub(crate) fn stage_handler(state: &State, id: &str) -> Response<std::io::Cursor<Vec<u8>>> {
665    let store = state.store.lock().unwrap();
666    let meta = match store.get_metadata(id) {
667        Ok(m) => m, Err(e) => return error_response(404, format!("{e}")),
668    };
669    let ast = match store.get_ast(id) {
670        Ok(a) => a, Err(e) => return error_response(404, format!("{e}")),
671    };
672    let status = format!("{:?}", store.get_status(id).unwrap_or(lex_store::StageStatus::Draft)).to_lowercase();
673    json_response(200, &serde_json::json!({
674        "metadata": meta,
675        "ast": ast,
676        "status": status,
677    }))
678}
679
680/// `GET /v1/stage/<id>/attestations` — every persisted attestation
681/// for this stage, newest-first by timestamp. Issue #132's
682/// queryable-evidence consumer surface.
683///
684/// 404s on unknown stage_id (matches `/v1/stage/<id>`'s shape so a
685/// caller round-tripping both endpoints sees consistent errors).
686/// Empty list (200) is *evidence of absence*: the stage exists but
687/// no producer has attested it.
688pub(crate) fn stage_attestations_handler(state: &State, id: &str) -> Response<std::io::Cursor<Vec<u8>>> {
689    let store = state.store.lock().unwrap();
690    if let Err(e) = store.get_metadata(id) {
691        return error_response(404, format!("{e}"));
692    }
693    let log = match store.attestation_log() {
694        Ok(l) => l,
695        Err(e) => return error_response(500, format!("attestation log: {e}")),
696    };
697    let mut listing = match log.list_for_stage(&id.to_string()) {
698        Ok(v) => v,
699        Err(e) => return error_response(500, format!("list_for_stage: {e}")),
700    };
701    listing.sort_by_key(|a| std::cmp::Reverse(a.timestamp));
702    json_response(200, &serde_json::json!({"attestations": listing}))
703}
704
705#[derive(Deserialize, Default)]
706struct PolicyJson {
707    #[serde(default)] allow_effects: Vec<String>,
708    #[serde(default)] allow_fs_read: Vec<String>,
709    #[serde(default)] allow_fs_write: Vec<String>,
710    #[serde(default)] budget: Option<u64>,
711}
712
713impl PolicyJson {
714    fn into_policy(self) -> Policy {
715        Policy {
716            allow_effects: self.allow_effects.into_iter().collect::<BTreeSet<_>>(),
717            allow_fs_read: self.allow_fs_read.into_iter().map(PathBuf::from).collect(),
718            allow_fs_write: self.allow_fs_write.into_iter().map(PathBuf::from).collect(),
719            allow_net_host: Vec::new(),
720            allow_proc: Vec::new(),
721            allow_approval: Vec::new(),
722            budget: self.budget,
723        }
724    }
725}
726
727#[derive(Deserialize)]
728struct RunReq {
729    source: String,
730    #[serde(rename = "fn")] func: String,
731    #[serde(default)] args: Vec<serde_json::Value>,
732    #[serde(default)] policy: PolicyJson,
733    #[serde(default)] overrides: IndexMap<String, serde_json::Value>,
734}
735
736pub(crate) fn run_handler(state: &State, body: &str, with_overrides: bool) -> Response<std::io::Cursor<Vec<u8>>> {
737    let req: RunReq = match serde_json::from_str(body) {
738        Ok(r) => r, Err(e) => return error_response(400, format!("bad request: {e}")),
739    };
740    let prog = match load_program_from_str(&req.source) {
741        Ok(p) => p, Err(e) => return error_response(400, format!("syntax error: {e}")),
742    };
743    let stages = canonicalize_program(&prog);
744    if let Err(errs) = lex_types::check_program(&stages) {
745        return error_with_detail(422, "type errors", serde_json::to_value(&errs).unwrap());
746    }
747    let bc = compile_program(&stages);
748    let mut policy = req.policy.into_policy();
749    // When a server-imposed ceiling is present (multi-tenant
750    // embedders like lex-hub), the request policy can only narrow
751    // it — never grant itself proc/fs/net beyond what the operator
752    // allowed. Single-tenant `lex serve` leaves the ceiling unset
753    // and runs the caller's policy verbatim.
754    if let Some(ceiling) = &state.policy_ceiling {
755        policy = clamp_policy(policy, ceiling);
756    }
757    if let Err(violations) = check_policy(&bc, &policy) {
758        return error_with_detail(403, "policy violation", serde_json::to_value(&violations).unwrap());
759    }
760
761    let mut recorder = lex_trace::Recorder::new();
762    if with_overrides && !req.overrides.is_empty() {
763        recorder = recorder.with_overrides(req.overrides);
764    }
765    let handle = recorder.handle();
766    let handler = DefaultHandler::new(policy);
767    let mut vm = Vm::with_handler(&bc, Box::new(handler));
768    vm.set_tracer(Box::new(recorder));
769
770    let vargs: Vec<Value> = req.args.iter().map(json_to_value).collect();
771    let started = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_secs();
772    let result = vm.call(&req.func, vargs);
773    let ended = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_secs();
774
775    let store = state.store.lock().unwrap();
776    let (root_out, root_err, status) = match &result {
777        Ok(v) => (Some(value_to_json(v)), None, 200u16),
778        Err(e) => (None, Some(format!("{e}")), 200u16),
779    };
780    let tree = handle.finalize(req.func.clone(), serde_json::Value::Null,
781        root_out.clone(), root_err.clone(), started, ended);
782    let run_id = match store.save_trace(&tree) {
783        Ok(id) => id,
784        Err(e) => return error_response(500, format!("save_trace: {e}")),
785    };
786
787    let mut body = serde_json::json!({
788        "run_id": run_id,
789        "output": root_out,
790    });
791    if let Some(err) = root_err {
792        body["error"] = serde_json::Value::String(err);
793    }
794    json_response(status, &body)
795}
796
797fn trace_handler(state: &State, id: &str) -> Response<std::io::Cursor<Vec<u8>>> {
798    let store = state.store.lock().unwrap();
799    match store.load_trace(id) {
800        Ok(t) => json_response(200, &serde_json::to_value(&t).unwrap()),
801        Err(e) => error_response(404, format!("{e}")),
802    }
803}
804
805fn diff_handler(state: &State, query: &str) -> Response<std::io::Cursor<Vec<u8>>> {
806    let mut a = None;
807    let mut b = None;
808    for kv in query.split('&') {
809        if let Some((k, v)) = kv.split_once('=') {
810            match k { "a" => a = Some(v.to_string()), "b" => b = Some(v.to_string()), _ => {} }
811        }
812    }
813    let (Some(a), Some(b)) = (a, b) else {
814        return error_response(400, "missing a or b query params");
815    };
816    let store = state.store.lock().unwrap();
817    let ta = match store.load_trace(&a) { Ok(t) => t, Err(e) => return error_response(404, format!("a: {e}")) };
818    let tb = match store.load_trace(&b) { Ok(t) => t, Err(e) => return error_response(404, format!("b: {e}")) };
819    match lex_trace::diff_runs(&ta, &tb) {
820        Some(d) => json_response(200, &serde_json::to_value(&d).unwrap()),
821        None => json_response(200, &serde_json::json!({"divergence": null})),
822    }
823}
824
825fn json_to_value(v: &serde_json::Value) -> Value { Value::from_json(v) }
826
827fn value_to_json(v: &Value) -> serde_json::Value { v.to_json() }
828
829#[derive(Deserialize)]
830struct MergeStartReq {
831    src_branch: String,
832    dst_branch: String,
833}
834
835/// `POST /v1/merge/start` (#134) — open a stateful merge between two
836/// branch heads and return the conflicts the agent needs to
837/// resolve. Auto-resolved sigs (one-sided changes, identical
838/// changes both sides) are returned for audit but don't block
839/// commit.
840///
841/// Response: `{ merge_id, src_head, dst_head, lca, conflicts,
842/// auto_resolved_count }`. The session is held in process memory
843/// keyed by `merge_id` for subsequent `resolve` / `commit` calls
844/// (next slices).
845fn merge_start_handler(state: &State, body: &str) -> Response<std::io::Cursor<Vec<u8>>> {
846    let req: MergeStartReq = match serde_json::from_str(body) {
847        Ok(r) => r, Err(e) => return error_response(400, format!("bad request: {e}")),
848    };
849    let store = state.store.lock().unwrap();
850    let src_head = match store.get_branch(&req.src_branch) {
851        Ok(Some(b)) => b.head_op,
852        Ok(None) => return error_response(404, format!("unknown src branch `{}`", req.src_branch)),
853        Err(e) => return error_response(500, format!("src branch read: {e}")),
854    };
855    let dst_head = match store.get_branch(&req.dst_branch) {
856        Ok(Some(b)) => b.head_op,
857        Ok(None) => return error_response(404, format!("unknown dst branch `{}`", req.dst_branch)),
858        Err(e) => return error_response(500, format!("dst branch read: {e}")),
859    };
860    let log = match lex_vcs::OpLog::open(store.root()) {
861        Ok(l) => l,
862        Err(e) => return error_response(500, format!("op log: {e}")),
863    };
864    // Caller doesn't choose merge_ids — minted server-side from
865    // wall clock + a per-process counter avoids leaking session
866    // ids' shape into the public surface.
867    let merge_id = mint_merge_id();
868    let session = match MergeSession::start(
869        merge_id.clone(),
870        &log,
871        src_head.as_ref(),
872        dst_head.as_ref(),
873    ) {
874        Ok(s) => s,
875        Err(e) => return error_response(500, format!("merge start: {e}")),
876    };
877    let conflicts: Vec<&lex_vcs::ConflictRecord> = session.remaining_conflicts();
878    let auto_resolved_count = session.auto_resolved.len();
879    let body = serde_json::json!({
880        "merge_id": merge_id,
881        "src_head": session.src_head,
882        "dst_head": session.dst_head,
883        "lca":      session.lca,
884        "conflicts": conflicts,
885        "auto_resolved_count": auto_resolved_count,
886    });
887    drop(conflicts);
888    drop(store);
889    let wrapped = ApiMergeSession {
890        inner: session,
891        src_branch: req.src_branch,
892        dst_branch: req.dst_branch,
893    };
894    state.sessions.lock().unwrap().insert(merge_id, wrapped);
895    json_response(200, &body)
896}
897
898#[derive(Deserialize)]
899struct MergeResolveReq {
900    /// Each entry is `(conflict_id, resolution)`. The resolution is
901    /// the same shape as `lex_vcs::Resolution`'s tagged JSON form
902    /// — `{"kind":"take_ours"}`, `{"kind":"take_theirs"}`,
903    /// `{"kind":"defer"}`, or `{"kind":"custom","op":{...}}`.
904    resolutions: Vec<MergeResolveEntry>,
905}
906
907#[derive(Deserialize)]
908struct MergeResolveEntry {
909    conflict_id: String,
910    resolution: lex_vcs::Resolution,
911}
912
913/// `POST /v1/merge/<id>/resolve` (#134) — submit batched
914/// resolutions against the conflicts surfaced by `merge/start`.
915/// Returns one verdict per input: accepted (recorded against the
916/// session) or rejected (with structured reason). The session
917/// stays alive across calls so an agent can iterate.
918///
919/// Errors:
920/// - 404 if `merge_id` doesn't refer to a live session (a typo
921///   or a session GC'd by a server restart).
922/// - 400 on malformed body.
923fn merge_resolve_handler(
924    state: &State,
925    merge_id: &str,
926    body: &str,
927) -> Response<std::io::Cursor<Vec<u8>>> {
928    let req: MergeResolveReq = match serde_json::from_str(body) {
929        Ok(r) => r, Err(e) => return error_response(400, format!("bad request: {e}")),
930    };
931    let mut sessions = state.sessions.lock().unwrap();
932    let Some(wrapped) = sessions.get_mut(merge_id) else {
933        return error_response(404, format!("unknown merge_id `{merge_id}`"));
934    };
935    let pairs: Vec<(String, lex_vcs::Resolution)> = req.resolutions.into_iter()
936        .map(|e| (e.conflict_id, e.resolution))
937        .collect();
938    let verdicts = wrapped.inner.resolve(pairs);
939    let remaining: Vec<&lex_vcs::ConflictRecord> = wrapped.inner.remaining_conflicts();
940    let body = serde_json::json!({
941        "verdicts": verdicts,
942        "remaining_conflicts": remaining,
943    });
944    json_response(200, &body)
945}
946
947/// `POST /v1/merge/<id>/commit` (#134) — finalize a merge
948/// session. Builds a `Merge` op from the auto-resolved sigs +
949/// the conflict resolutions, applies it to the dst branch, and
950/// returns the new head op id. The session is dropped on
951/// success; the caller would re-run `merge/start` to land
952/// further changes.
953///
954/// Errors:
955/// - 404: unknown `merge_id`.
956/// - 422: conflicts remaining (pass `Defer` or just don't
957///   resolve a conflict and you land here). Body carries the
958///   list so the caller knows which still need attention.
959/// - 422: a `Custom` resolution was used. The data layer
960///   supports them but landing them via HTTP needs an extra
961///   pass to apply the custom op against the dst branch
962///   first; deferred to a follow-up slice. Use TakeOurs /
963///   TakeTheirs for now.
964/// - 500: filesystem error while landing the merge op.
965fn merge_commit_handler(
966    state: &State,
967    merge_id: &str,
968) -> Response<std::io::Cursor<Vec<u8>>> {
969    use std::collections::BTreeMap;
970    let wrapped = match state.sessions.lock().unwrap().remove(merge_id) {
971        Some(w) => w,
972        None => return error_response(404, format!("unknown merge_id `{merge_id}`")),
973    };
974    let dst_branch = wrapped.dst_branch.clone();
975    let src_head = wrapped.inner.src_head.clone();
976    let dst_head = wrapped.inner.dst_head.clone();
977    let auto_resolved = wrapped.inner.auto_resolved.clone();
978
979    // Translate auto-resolved + resolutions into the StageTransition::Merge
980    // entries map. Only sigs whose head changes relative to dst go in.
981    let mut entries: BTreeMap<lex_vcs::SigId, Option<lex_vcs::StageId>> = BTreeMap::new();
982
983    // Auto-resolved: only `Src` (one-sided change on src) modifies dst.
984    for outcome in &auto_resolved {
985        if let lex_vcs::MergeOutcome::Src { sig_id, stage_id } = outcome {
986            entries.insert(sig_id.clone(), stage_id.clone());
987        }
988    }
989
990    // Conflict resolutions.
991    let resolved = match wrapped.inner.commit() {
992        Ok(r) => r,
993        Err(lex_vcs::CommitError::ConflictsRemaining(ids)) => {
994            // Re-insert isn't possible since we removed above; the
995            // caller will need to re-start. That's acceptable: a
996            // commit-with-unresolved-conflicts is operator error.
997            return error_with_detail(
998                422,
999                "conflicts remaining",
1000                serde_json::json!({"unresolved": ids}),
1001            );
1002        }
1003    };
1004
1005    for (conflict_id, resolution) in resolved {
1006        match resolution {
1007            lex_vcs::Resolution::TakeOurs => {
1008                // Dst already has its head. No entry needed.
1009            }
1010            lex_vcs::Resolution::TakeTheirs => {
1011                // Find the conflict's `theirs` stage_id in the
1012                // session snapshot. We don't have direct access to
1013                // it post-commit (commit consumed the session); but
1014                // we can reconstruct from `auto_resolved` plus the
1015                // session's pre-commit conflict map. Since we
1016                // already moved the inner session, the cleanest fix
1017                // for this slice is to rebuild from the on-disk
1018                // graph: walk src_head, find the latest stage for
1019                // the conflict's sig.
1020                match resolve_take_theirs(state, &src_head, &conflict_id) {
1021                    Ok(stage_id) => {
1022                        entries.insert(conflict_id.clone(), stage_id);
1023                    }
1024                    Err(e) => return error_response(500, format!("resolve take_theirs: {e}")),
1025                }
1026            }
1027            lex_vcs::Resolution::Custom { op } => {
1028                // The agent's brand-new op carries the merge target
1029                // in its kind (e.g. ModifyBody.to_stage_id). The op
1030                // itself isn't separately recorded in the log here
1031                // — its head-map effect is folded into the merge
1032                // op's entries map. Callers that want the op as a
1033                // first-class history entry should publish it via
1034                // /v1/publish first and submit a TakeTheirs/TakeOurs
1035                // resolution against the resulting head.
1036                match op.kind.merge_target() {
1037                    Some((sig, stage)) => {
1038                        if sig != conflict_id {
1039                            return error_with_detail(
1040                                422,
1041                                "custom op targets a different sig than the conflict",
1042                                serde_json::json!({
1043                                    "conflict_id": conflict_id,
1044                                    "op_targets": sig,
1045                                }),
1046                            );
1047                        }
1048                        entries.insert(conflict_id, stage);
1049                    }
1050                    None => {
1051                        return error_with_detail(
1052                            422,
1053                            "custom op kind doesn't yield a single sig→stage delta",
1054                            serde_json::json!({
1055                                "conflict_id": conflict_id,
1056                                "kind": serde_json::to_value(&op.kind).unwrap_or(serde_json::Value::Null),
1057                            }),
1058                        );
1059                    }
1060                }
1061            }
1062            lex_vcs::Resolution::Defer => {
1063                // Unreachable: commit() rejects Defer above.
1064                return error_response(500, "internal: Defer slipped past commit gate");
1065            }
1066        }
1067    }
1068
1069    let resolved_count = entries.len();
1070    let mut parents: Vec<lex_vcs::OpId> = Vec::new();
1071    if let Some(d) = dst_head { parents.push(d); }
1072    if let Some(s) = src_head { parents.push(s); }
1073    let op = lex_vcs::Operation::new(
1074        lex_vcs::OperationKind::Merge { resolved: resolved_count },
1075        parents,
1076    );
1077    let transition = lex_vcs::StageTransition::Merge { entries };
1078    let store = state.store.lock().unwrap();
1079    match store.apply_operation(&dst_branch, op, transition) {
1080        Ok(new_head_op) => json_response(200, &serde_json::json!({
1081            "new_head_op": new_head_op,
1082            "dst_branch": dst_branch,
1083        })),
1084        Err(e) => write_error_response("apply merge op", e),
1085    }
1086}
1087
1088/// Walk the op log from `src_head` backwards to find the latest
1089/// stage assigned to `sig`. Used by the commit handler to figure
1090/// out what stage `TakeTheirs` should land. `Ok(None)` means src
1091/// removed the sig.
1092fn resolve_take_theirs(
1093    state: &State,
1094    src_head: &Option<lex_vcs::OpId>,
1095    sig: &lex_vcs::SigId,
1096) -> std::io::Result<Option<lex_vcs::StageId>> {
1097    let store = state.store.lock().unwrap();
1098    let log = lex_vcs::OpLog::open(store.root())?;
1099    let Some(head) = src_head.as_ref() else { return Ok(None); };
1100    // Walk forward from root → head, replaying each op's transition
1101    // for `sig`; the last assignment wins.
1102    let mut current: Option<lex_vcs::StageId> = None;
1103    for record in log.walk_forward(head, None)? {
1104        match &record.produces {
1105            lex_vcs::StageTransition::Create { sig_id, stage_id }
1106                if sig_id == sig => { current = Some(stage_id.clone()); }
1107            lex_vcs::StageTransition::Replace { sig_id, to, .. }
1108                if sig_id == sig => { current = Some(to.clone()); }
1109            lex_vcs::StageTransition::Remove { sig_id, .. }
1110                if sig_id == sig => { current = None; }
1111            lex_vcs::StageTransition::Rename { from, to, body_stage_id }
1112                if from == sig || to == sig => {
1113                if from == sig { current = None; }
1114                if to == sig   { current = Some(body_stage_id.clone()); }
1115            }
1116            lex_vcs::StageTransition::Merge { entries } => {
1117                if let Some(opt) = entries.get(sig) {
1118                    current = opt.clone();
1119                }
1120            }
1121            _ => {}
1122        }
1123    }
1124    Ok(current)
1125}
1126
1127fn mint_merge_id() -> MergeSessionId {
1128    use std::sync::atomic::{AtomicU64, Ordering};
1129    static COUNTER: AtomicU64 = AtomicU64::new(0);
1130    let nanos = SystemTime::now()
1131        .duration_since(UNIX_EPOCH)
1132        .map(|d| d.as_nanos())
1133        .unwrap_or(0);
1134    let n = COUNTER.fetch_add(1, Ordering::Relaxed);
1135    format!("merge_{nanos:x}_{n:x}")
1136}
1137
1138// ---- #242: append-only sync ---------------------------------------
1139
1140/// `POST /v1/ops/batch` (#242). Server endpoint for `lex op push`.
1141///
1142/// Body: a JSON array of `OperationRecord`s. The handler validates
1143/// DAG integrity by checking that every op's `parents` either
1144/// already exist on the remote *or* appear earlier in the same
1145/// batch. This lets a client send a topologically-ordered slice
1146/// without first probing for what's already there.
1147///
1148/// Response shape:
1149///
1150/// ```json
1151/// { "received": N, "added": M, "skipped": (N-M), "added_ids": [...] }
1152/// ```
1153///
1154/// Failure modes:
1155///
1156/// * `400` — body isn't a JSON array of op records.
1157/// * `422` with `{ "error": "MissingParent", "detail": { "op_id":
1158///   ..., "missing_parent": ... } }` if a parent is unreachable.
1159///   The whole batch is rejected; nothing is persisted. The client
1160///   should backfill the missing op and retry.
1161/// * `409` if the supplied `op_id` doesn't match the canonical
1162///   hash of the record's payload — content addressing must hold
1163///   over the wire.
1164///
1165/// Idempotency: a record whose `op_id` already exists is silently
1166/// skipped (not added, not rejected). Pushing the same payload
1167/// twice is `received == N, added == 0` on the second call.
1168pub(crate) fn ops_batch_handler(state: &State, body: &str)
1169    -> Response<std::io::Cursor<Vec<u8>>>
1170{
1171    let records: Vec<lex_vcs::OperationRecord> = match serde_json::from_str(body) {
1172        Ok(r) => r,
1173        Err(e) => return error_response(400,
1174            format!("body must be a JSON array of OperationRecord: {e}")),
1175    };
1176    let store = state.store.lock().unwrap();
1177    let log = match lex_vcs::OpLog::open(store.root()) {
1178        Ok(l) => l,
1179        Err(e) => return error_response(500, format!("opening op log: {e}")),
1180    };
1181
1182    // Validate every record before persisting any of them.
1183    //
1184    // 1. Content-addressing: the supplied `op_id` must match the
1185    //    canonical hash of `record.op`. Otherwise the client is
1186    //    sending a forged or corrupted record.
1187    // 2. DAG integrity: every parent must either already exist in
1188    //    the local log OR appear earlier in this batch.
1189    let mut batch_ids: std::collections::BTreeSet<lex_vcs::OpId> =
1190        std::collections::BTreeSet::new();
1191    for rec in &records {
1192        let expected = rec.op.op_id();
1193        if expected != rec.op_id {
1194            return error_with_detail(409, "OpIdMismatch", serde_json::json!({
1195                "supplied": rec.op_id,
1196                "expected": expected,
1197            }));
1198        }
1199        for parent in &rec.op.parents {
1200            let known = match log.get(parent) {
1201                Ok(Some(_)) => true,
1202                Ok(None) => false,
1203                Err(e) => return error_response(500, format!("op log read: {e}")),
1204            };
1205            if !known && !batch_ids.contains(parent) {
1206                return error_with_detail(422, "MissingParent", serde_json::json!({
1207                    "op_id": rec.op_id,
1208                    "missing_parent": parent,
1209                }));
1210            }
1211        }
1212        batch_ids.insert(rec.op_id.clone());
1213    }
1214
1215    // Persist. `OpLog::put` is idempotent so a re-push is a no-op
1216    // for already-present records.
1217    let mut added = 0usize;
1218    let mut added_ids: Vec<&lex_vcs::OpId> = Vec::new();
1219    for rec in &records {
1220        let already_present = matches!(log.get(&rec.op_id), Ok(Some(_)));
1221        match log.put(rec) {
1222            Ok(()) => {
1223                if !already_present {
1224                    added += 1;
1225                    added_ids.push(&rec.op_id);
1226                }
1227            }
1228            Err(e) => return error_response(500, format!("op log write: {e}")),
1229        }
1230    }
1231
1232    json_response(200, &serde_json::json!({
1233        "received": records.len(),
1234        "added": added,
1235        "skipped": records.len() - added,
1236        "added_ids": added_ids,
1237    }))
1238}
1239
1240/// `POST /v1/attestations/batch` (#242). Server endpoint for `lex
1241/// attest push`.
1242///
1243/// Body: a JSON array of `Attestation`s. Validates that each
1244/// attestation's `op_id` (when set) refers to an op that already
1245/// exists on the remote — `attestation_id` is then re-derivable
1246/// from the canonical form, so cross-store dedup just works.
1247///
1248/// Response: same shape as `ops_batch_handler` but `added_ids` is
1249/// the list of accepted `attestation_id`s.
1250///
1251/// Failure modes:
1252///
1253/// * `400` for malformed JSON.
1254/// * `422` with `{ "error": "UnknownOp", "detail": { ... } }` if
1255///   an attestation's `op_id` references an op the remote doesn't
1256///   know about. Whole batch rejected.
1257/// * `409` `AttestationIdMismatch` if the supplied id doesn't
1258///   match the canonical hash.
1259///
1260/// Idempotency: same as the ops endpoint — content-addressed dedup.
1261pub(crate) fn attestations_batch_handler(state: &State, body: &str)
1262    -> Response<std::io::Cursor<Vec<u8>>>
1263{
1264    let attestations: Vec<lex_vcs::Attestation> = match serde_json::from_str(body) {
1265        Ok(a) => a,
1266        Err(e) => return error_response(400,
1267            format!("body must be a JSON array of Attestation: {e}")),
1268    };
1269    let store = state.store.lock().unwrap();
1270    let log = match store.attestation_log() {
1271        Ok(l) => l,
1272        Err(e) => return error_response(500, format!("opening attestation log: {e}")),
1273    };
1274    let op_log = match lex_vcs::OpLog::open(store.root()) {
1275        Ok(l) => l,
1276        Err(e) => return error_response(500, format!("opening op log: {e}")),
1277    };
1278
1279    // Validate before persisting any record.
1280    for att in &attestations {
1281        // Content-addressing: re-derive attestation_id from the
1282        // payload and reject mismatches.
1283        let expected = lex_vcs::Attestation::with_timestamp(
1284            att.stage_id.clone(),
1285            att.op_id.clone(),
1286            att.intent_id.clone(),
1287            att.kind.clone(),
1288            att.result.clone(),
1289            att.produced_by.clone(),
1290            att.cost.clone(),
1291            att.timestamp,
1292        ).attestation_id;
1293        if expected != att.attestation_id {
1294            return error_with_detail(409, "AttestationIdMismatch", serde_json::json!({
1295                "supplied": att.attestation_id,
1296                "expected": expected,
1297            }));
1298        }
1299        // The op_id field, if set, must point at an op the remote
1300        // knows about. Without this check, attestations would
1301        // dangle into a future sync that never lands the op.
1302        if let Some(op_id) = &att.op_id {
1303            match op_log.get(op_id) {
1304                Ok(Some(_)) => {}
1305                Ok(None) => return error_with_detail(422, "UnknownOp", serde_json::json!({
1306                    "attestation_id": att.attestation_id,
1307                    "op_id": op_id,
1308                })),
1309                Err(e) => return error_response(500, format!("op log read: {e}")),
1310            }
1311        }
1312    }
1313
1314    // Persist. `AttestationLog::put` is idempotent on
1315    // `attestation_id` and the by-stage index is rewritten as a
1316    // marker file, also idempotent.
1317    let mut added = 0usize;
1318    let mut added_ids: Vec<&lex_vcs::AttestationId> = Vec::new();
1319    for att in &attestations {
1320        let already_present = matches!(log.get(&att.attestation_id), Ok(Some(_)));
1321        match log.put(att) {
1322            Ok(()) => {
1323                if !already_present {
1324                    added += 1;
1325                    added_ids.push(&att.attestation_id);
1326                }
1327            }
1328            Err(e) => return error_response(500, format!("attestation log write: {e}")),
1329        }
1330    }
1331
1332    json_response(200, &serde_json::json!({
1333        "received": attestations.len(),
1334        "added": added,
1335        "skipped": attestations.len() - added,
1336        "added_ids": added_ids,
1337    }))
1338}
1339
1340/// `GET /v1/branches/<name>/head` (#242 follow-up). Probe endpoint
1341/// the `lex op push` client uses to discover the remote head before
1342/// computing a delta against `OpLog::ops_since`.
1343///
1344/// Response: `{ "branch": "main", "head_op": Option<OpId> }`.
1345/// Returns 200 even when the branch doesn't exist locally — the
1346/// answer in that case is `head_op: null`, which is the right
1347/// signal for "send everything you have."
1348pub(crate) fn branch_head_handler(state: &State, name: &str)
1349    -> Response<std::io::Cursor<Vec<u8>>>
1350{
1351    let store = state.store.lock().unwrap();
1352    let head = match store.get_branch(name) {
1353        Ok(Some(b)) => b.head_op,
1354        Ok(None) => None,
1355        Err(e) => return error_response(500, format!("get_branch: {e}")),
1356    };
1357    json_response(200, &serde_json::json!({
1358        "branch": name,
1359        "head_op": head,
1360    }))
1361}
1362
1363/// `GET /v1/ops/since?after=<op_id>&branch=<name>&limit=<n>` (#260).
1364/// Server endpoint for `lex op pull`.
1365///
1366/// Returns a JSON array of `OperationRecord`s reachable from
1367/// `branch.head_op` but not from `<after>`, sorted **oldest-first**
1368/// so the client can apply them in topological order without
1369/// re-sorting. Empty array when:
1370///
1371/// * The branch doesn't exist on the remote.
1372/// * The branch's `head_op` is `None`.
1373/// * `after == branch.head_op` (caller is already at the remote's head).
1374/// * `after` is *ahead of* the remote's head (caller is past the
1375///   remote — the symmetric "remote behind" case from #260).
1376///
1377/// `branch` defaults to `main`. `limit` caps the response — useful
1378/// for chunked pulls of large gaps; clients re-issue with the next
1379/// `after` once the prefix has landed.
1380///
1381/// Failure modes:
1382///
1383/// * `400` if the query string is malformed.
1384/// * `200` with `[]` for any of the empty-result cases above. "Caller
1385///   is already up to date" is a normal answer, not an error.
1386pub(crate) fn ops_since_handler(state: &State, query: &str)
1387    -> Response<std::io::Cursor<Vec<u8>>>
1388{
1389    let mut after: Option<String> = None;
1390    let mut branch = String::from("main");
1391    let mut limit: Option<usize> = None;
1392    for kv in query.split('&') {
1393        let Some((k, v)) = kv.split_once('=') else { continue };
1394        match k {
1395            "after" => after = Some(v.to_string()),
1396            "branch" => branch = v.to_string(),
1397            "limit" => {
1398                limit = Some(match v.parse::<usize>() {
1399                    Ok(n) => n,
1400                    Err(_) => return error_response(400,
1401                        format!("limit must be a positive integer, got `{v}`")),
1402                });
1403            }
1404            _ => {}
1405        }
1406    }
1407
1408    let store = state.store.lock().unwrap();
1409    let log = match lex_vcs::OpLog::open(store.root()) {
1410        Ok(l) => l,
1411        Err(e) => return error_response(500, format!("opening op log: {e}")),
1412    };
1413    let head = match store.get_branch(&branch) {
1414        Ok(Some(b)) => b.head_op,
1415        Ok(None) => None,
1416        Err(e) => return error_response(500, format!("get_branch: {e}")),
1417    };
1418    let Some(head) = head else {
1419        return json_response(200, &serde_json::json!([]));
1420    };
1421
1422    let ops_since = match log.ops_since(&head, after.as_ref()) {
1423        Ok(o) => o,
1424        Err(e) => return error_response(500, format!("ops_since: {e}")),
1425    };
1426    // ops_since walks newest-first; reverse so the client receives
1427    // oldest-first and can apply them in topological order with
1428    // `OpLog::put` straight through.
1429    let mut ops = ops_since;
1430    ops.reverse();
1431    if let Some(n) = limit {
1432        ops.truncate(n);
1433    }
1434
1435    json_response(200, &serde_json::to_value(&ops).unwrap_or_default())
1436}
1437
1438/// `GET /v1/attestations/since?after-op=<op_id>&limit=<n>` (#260).
1439/// Mirror of `ops_since_handler` for the attestation log.
1440///
1441/// Returns attestations whose `op_id` field is reachable from
1442/// **any** branch's head — not just one — and not in `after_op`'s
1443/// ancestry. The cross-branch fan-out matches the push side:
1444/// attestations are stage-keyed, not branch-keyed, so a single
1445/// "since this op" filter is the right shape.
1446///
1447/// Attestations with `op_id: None` (e.g. `Override`,
1448/// `ProducerBlock`) are always included — the cutoff doesn't apply.
1449/// `--limit` caps the response.
1450pub(crate) fn attestations_since_handler(state: &State, query: &str)
1451    -> Response<std::io::Cursor<Vec<u8>>>
1452{
1453    let mut after_op: Option<String> = None;
1454    let mut limit: Option<usize> = None;
1455    for kv in query.split('&') {
1456        let Some((k, v)) = kv.split_once('=') else { continue };
1457        match k {
1458            "after-op" => after_op = Some(v.to_string()),
1459            "limit" => {
1460                limit = Some(match v.parse::<usize>() {
1461                    Ok(n) => n,
1462                    Err(_) => return error_response(400,
1463                        format!("limit must be a positive integer, got `{v}`")),
1464                });
1465            }
1466            _ => {}
1467        }
1468    }
1469
1470    let store = state.store.lock().unwrap();
1471    let log = match store.attestation_log() {
1472        Ok(l) => l,
1473        Err(e) => return error_response(500, format!("opening attestation log: {e}")),
1474    };
1475
1476    // Build the exclude set: every op_id reachable from `after_op`,
1477    // inclusive. Attestations whose op_id is in this set were
1478    // already known to the caller.
1479    let exclude: std::collections::BTreeSet<String> = match &after_op {
1480        None => std::collections::BTreeSet::new(),
1481        Some(cutoff) => {
1482            let op_log = match lex_vcs::OpLog::open(store.root()) {
1483                Ok(l) => l,
1484                Err(e) => return error_response(500, format!("opening op log: {e}")),
1485            };
1486            match op_log.walk_back(cutoff, None) {
1487                Ok(records) => records.into_iter().map(|r| r.op_id).collect(),
1488                Err(_) => {
1489                    // Cutoff op doesn't exist on this remote. Treat
1490                    // as "no exclude" — caller will get every
1491                    // attestation. They may dedup client-side.
1492                    std::collections::BTreeSet::new()
1493                }
1494            }
1495        }
1496    };
1497
1498    let all = match log.list_all() {
1499        Ok(v) => v,
1500        Err(e) => return error_response(500, format!("listing attestations: {e}")),
1501    };
1502    let mut filtered: Vec<lex_vcs::Attestation> = all
1503        .into_iter()
1504        .filter(|a| match &a.op_id {
1505            Some(op_id) => !exclude.contains(op_id),
1506            // No op_id = doesn't participate in the cutoff; always
1507            // ship it on the first pull, server-side idempotency
1508            // dedupes on the client.
1509            None => true,
1510        })
1511        .collect();
1512    // Stable order: oldest-first by `timestamp`, then by
1513    // `attestation_id` for ties. Lets the client land them
1514    // deterministically.
1515    filtered.sort_by(|a, b| {
1516        a.timestamp.cmp(&b.timestamp)
1517            .then_with(|| a.attestation_id.cmp(&b.attestation_id))
1518    });
1519    if let Some(n) = limit {
1520        filtered.truncate(n);
1521    }
1522
1523    json_response(200, &serde_json::to_value(&filtered).unwrap_or_default())
1524}
1525
1526// ── Package concept (#4) ────────────────────────────────────────────────────
1527
1528/// Per-version record stored at `{store_root}/packages/{name}/{version}.json`.
1529#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
1530struct PkgRecord {
1531    name: String,
1532    version: String,
1533    head_op: Option<String>,
1534    published_at: u64,
1535    /// Function names introduced or updated by this version (for retract).
1536    function_names: Vec<String>,
1537    /// Raw op JSON from each file in this publish.
1538    ops: Vec<serde_json::Value>,
1539}
1540
1541/// Whether a package is reachable without authentication.
1542///
1543/// Per-package (applies across all versions), GitHub-style: an org's
1544/// store can hold a mix of public and private packages. Defaults to
1545/// `Private`, so an index written before this field existed (or any
1546/// freshly published package) is private until an owner opts in.
1547#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize, Default)]
1548#[serde(rename_all = "lowercase")]
1549pub enum Visibility {
1550    #[default]
1551    Private,
1552    Public,
1553}
1554
1555/// Index stored at `{store_root}/packages/{name}/index.json`.
1556///
1557/// Tracks which versions have been published and which is "latest", so
1558/// consumers can resolve `{name}@latest` without listing every file.
1559#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, Default)]
1560struct PkgIndex {
1561    /// The most recently published version string (human label, not OpId).
1562    latest: Option<String>,
1563    /// All published versions, newest-last.
1564    versions: Vec<PkgVersionSummary>,
1565    /// Public/private flag. `#[serde(default)]` keeps pre-existing
1566    /// `index.json` files (which lack the field) deserializing as
1567    /// `Private`.
1568    #[serde(default)]
1569    visibility: Visibility,
1570}
1571
1572#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
1573struct PkgVersionSummary {
1574    version: String,
1575    head_op: Option<String>,
1576    published_at: u64,
1577}
1578
1579fn pkg_name_dir(root: &std::path::Path, name: &str) -> PathBuf {
1580    root.join("packages").join(name)
1581}
1582
1583fn pkg_index_path(root: &std::path::Path, name: &str) -> PathBuf {
1584    pkg_name_dir(root, name).join("index.json")
1585}
1586
1587fn pkg_version_path(root: &std::path::Path, name: &str, version: &str) -> PathBuf {
1588    pkg_name_dir(root, name).join(format!("{version}.json"))
1589}
1590
1591fn pkg_archive_path(root: &std::path::Path, name: &str, version: &str) -> PathBuf {
1592    pkg_name_dir(root, name).join(format!("{version}.tar.gz"))
1593}
1594
1595fn load_pkg_index(root: &std::path::Path, name: &str) -> Option<PkgIndex> {
1596    let bytes = std::fs::read(pkg_index_path(root, name)).ok()?;
1597    serde_json::from_slice(&bytes).ok()
1598}
1599
1600fn load_pkg_record(root: &std::path::Path, name: &str, version: &str) -> Option<PkgRecord> {
1601    let bytes = std::fs::read(pkg_version_path(root, name, version)).ok()?;
1602    serde_json::from_slice(&bytes).ok()
1603}
1604
1605fn load_latest_pkg_record(root: &std::path::Path, name: &str) -> Option<PkgRecord> {
1606    let index = load_pkg_index(root, name)?;
1607    let latest = index.latest.clone()?;
1608    load_pkg_record(root, name, &latest)
1609}
1610
1611/// A package is public iff its index exists and is marked `Public`.
1612/// A missing index (unknown package) is treated as private, so the
1613/// public surface never distinguishes "private" from "does not exist".
1614fn pkg_is_public(root: &std::path::Path, name: &str) -> bool {
1615    load_pkg_index(root, name).map(|i| i.visibility) == Some(Visibility::Public)
1616}
1617
1618/// Reject package/version path segments that could escape the
1619/// `packages/` directory or otherwise aren't valid names. Mirrors the
1620/// tenant-id guard's spirit (defense in depth — lex-hub validates the
1621/// tenant, this validates the package/version).
1622fn valid_pkg_segment(s: &str) -> bool {
1623    !s.is_empty()
1624        && s.len() <= 128
1625        && s != "."
1626        && s != ".."
1627        && s.chars().all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '-'))
1628}
1629
1630#[derive(Deserialize)]
1631struct VisibilityReq {
1632    visibility: Visibility,
1633}
1634
1635/// `PUT /v1/pkg/{name}/visibility` — set a package public or private.
1636///
1637/// Authorization is implicit: this runs against a single tenant's store,
1638/// and the caller only reaches *this* store because the front door
1639/// (lex-hub) authenticated their token and selected it. A caller can
1640/// therefore only change visibility of packages they own.
1641fn pkg_set_visibility_handler(
1642    state: &State,
1643    name: &str,
1644    body: &str,
1645) -> Response<std::io::Cursor<Vec<u8>>> {
1646    if !valid_pkg_segment(name) {
1647        return error_response(400, format!("invalid package name {name:?}"));
1648    }
1649    let req: VisibilityReq = match serde_json::from_str(body) {
1650        Ok(r) => r,
1651        Err(e) => return error_response(400, format!("bad request: {e}")),
1652    };
1653    let mut index = match load_pkg_index(&state.root, name) {
1654        Some(i) => i,
1655        None => return error_response(404, format!("package {name:?} not found")),
1656    };
1657    index.visibility = req.visibility;
1658    let bytes = serde_json::to_vec_pretty(&index).unwrap_or_default();
1659    match std::fs::write(pkg_index_path(&state.root, name), bytes) {
1660        Ok(()) => json_response(
1661            200,
1662            &serde_json::json!({ "name": name, "visibility": index.visibility }),
1663        ),
1664        Err(e) => error_response(500, format!("write index: {e}")),
1665    }
1666}
1667
1668/// Names of the tenant's PUBLIC packages, sorted. Pure (filesystem in,
1669/// names out) so the visibility filter is unit-testable.
1670fn public_pkg_names(root: &std::path::Path) -> Vec<String> {
1671    list_pkg_names(root)
1672        .into_iter()
1673        .filter(|name| pkg_is_public(root, name))
1674        .collect()
1675}
1676
1677/// `GET /v1/public/<tenant>` (org page) — list only the tenant's PUBLIC
1678/// packages (latest version of each). Private packages are omitted, so
1679/// their existence is not revealed.
1680fn public_pkg_list_handler(state: &State) -> Response<std::io::Cursor<Vec<u8>>> {
1681    let packages: Vec<serde_json::Value> = public_pkg_names(&state.root)
1682        .iter()
1683        .filter_map(|name| {
1684            let r = load_latest_pkg_record(&state.root, name)?;
1685            Some(serde_json::json!({
1686                "name": r.name,
1687                "version": r.version,
1688                "head_op": r.head_op,
1689                "published_at": r.published_at,
1690            }))
1691        })
1692        .collect();
1693    json_response(200, &serde_json::json!({ "packages": packages }))
1694}
1695
1696/// A resolved public read target. Separated from response formatting so
1697/// the routing/guard logic is unit-testable without constructing HTTP
1698/// responses. `Err(status)` is a guard failure (405 = non-GET,
1699/// 404 = invalid/unknown route).
1700#[derive(Debug, PartialEq, Eq)]
1701enum PublicTarget {
1702    List,
1703    Latest(String),
1704    Versions(String),
1705    Head(String),
1706    Version(String, String),
1707    Archive(String, String),
1708}
1709
1710impl PublicTarget {
1711    /// The package name a target refers to, if any (`List` has none).
1712    fn pkg_name(&self) -> Option<&str> {
1713        match self {
1714            PublicTarget::List => None,
1715            PublicTarget::Latest(n)
1716            | PublicTarget::Versions(n)
1717            | PublicTarget::Head(n)
1718            | PublicTarget::Version(n, _)
1719            | PublicTarget::Archive(n, _) => Some(n),
1720        }
1721    }
1722}
1723
1724/// Pure routing decision for `/v1/public/<tenant>` reads. `path` is the
1725/// portion after the tenant (leading `/` ok). Enforces GET-only and
1726/// per-segment name validation; does NOT consult the store (visibility
1727/// is checked by the caller, which has store access).
1728fn resolve_public(method: &Method, path: &str) -> Result<PublicTarget, u16> {
1729    if !matches!(method, Method::Get) {
1730        return Err(405);
1731    }
1732    let rest = path.trim_matches('/');
1733    if rest.is_empty() {
1734        return Ok(PublicTarget::List);
1735    }
1736    let segs: Vec<&str> = rest.split('/').collect();
1737    if !segs.iter().all(|s| valid_pkg_segment(s)) {
1738        return Err(404);
1739    }
1740    match segs.as_slice() {
1741        [n] => Ok(PublicTarget::Latest(n.to_string())),
1742        [n, "versions"] => Ok(PublicTarget::Versions(n.to_string())),
1743        [n, "head"] => Ok(PublicTarget::Head(n.to_string())),
1744        [n, v, "archive"] => Ok(PublicTarget::Archive(n.to_string(), v.to_string())),
1745        [n, v] => Ok(PublicTarget::Version(n.to_string(), v.to_string())),
1746        _ => Err(404),
1747    }
1748}
1749
1750/// Unauthenticated, read-only access to **public** packages in `state`'s
1751/// store. `path` is the portion of the URL after `/v1/public/<tenant>`
1752/// (with a leading `/`); lex-hub resolves `<tenant>` → store and calls
1753/// this. Visibility gating, GET-only enforcement, and segment validation
1754/// all live here so the whole public surface is auditable in one place.
1755///
1756/// Everything served here is package-scoped — manifests and the source
1757/// archive of a public package's own publish — so it cannot leak code
1758/// from a private package that happens to share content-addressed stages
1759/// in the same store.
1760pub fn route_public(
1761    state: &State,
1762    method: &Method,
1763    path: &str,
1764    _query: &str,
1765) -> Response<std::io::Cursor<Vec<u8>>> {
1766    let target = match resolve_public(method, path) {
1767        Ok(t) => t,
1768        Err(405) => return error_response(405, "public read is GET-only"),
1769        Err(_) => return error_response(404, "not found"),
1770    };
1771    // The org listing already filters to public packages itself.
1772    if let PublicTarget::List = target {
1773        return public_pkg_list_handler(state);
1774    }
1775    // Single 404 for both "private" and "absent" — never reveal which.
1776    if let Some(name) = target.pkg_name() {
1777        if !pkg_is_public(&state.root, name) {
1778            return error_response(404, format!("package {name:?} not found"));
1779        }
1780    }
1781    match target {
1782        PublicTarget::List => unreachable!("handled above"),
1783        PublicTarget::Latest(n) => pkg_get_handler(state, &n),
1784        PublicTarget::Versions(n) => pkg_versions_handler(state, &n),
1785        PublicTarget::Head(n) => pkg_head_handler(state, &n),
1786        PublicTarget::Version(n, v) => pkg_get_version_handler(state, &n, &v),
1787        PublicTarget::Archive(n, v) => pkg_archive_handler(state, &n, &v),
1788    }
1789}
1790
1791fn save_pkg_record(
1792    root: &std::path::Path,
1793    record: &PkgRecord,
1794    archive: &[u8],
1795) -> std::io::Result<()> {
1796    let dir = pkg_name_dir(root, &record.name);
1797    std::fs::create_dir_all(&dir)?;
1798
1799    // Per-version record.
1800    let rec_bytes = serde_json::to_vec_pretty(record).unwrap_or_default();
1801    std::fs::write(pkg_version_path(root, &record.name, &record.version), rec_bytes)?;
1802
1803    // Archive (tar.gz) for the download endpoint.
1804    std::fs::write(pkg_archive_path(root, &record.name, &record.version), archive)?;
1805
1806    // Update the index.
1807    let mut index = load_pkg_index(root, &record.name).unwrap_or_default();
1808    index.latest = Some(record.version.clone());
1809    if !index.versions.iter().any(|v| v.version == record.version) {
1810        index.versions.push(PkgVersionSummary {
1811            version: record.version.clone(),
1812            head_op: record.head_op.clone(),
1813            published_at: record.published_at,
1814        });
1815    }
1816    let idx_bytes = serde_json::to_vec_pretty(&index).unwrap_or_default();
1817    std::fs::write(pkg_index_path(root, &record.name), idx_bytes)
1818}
1819
1820fn list_pkg_names(root: &std::path::Path) -> Vec<String> {
1821    let dir = root.join("packages");
1822    let Ok(entries) = std::fs::read_dir(&dir) else {
1823        return Vec::new();
1824    };
1825    let mut names: Vec<String> = entries
1826        .filter_map(|e| e.ok())
1827        .filter(|e| e.path().is_dir())
1828        .filter_map(|e| e.file_name().into_string().ok())
1829        .collect();
1830    names.sort();
1831    names
1832}
1833
1834fn collect_lex_files(dir: &std::path::Path, out: &mut Vec<PathBuf>) {
1835    let Ok(entries) = std::fs::read_dir(dir) else { return };
1836    let mut entries: Vec<_> = entries.filter_map(|e| e.ok()).collect();
1837    entries.sort_by_key(|e| e.path());
1838    for entry in entries {
1839        let path = entry.path();
1840        if path.is_dir() {
1841            collect_lex_files(&path, out);
1842        } else if path.extension().and_then(|x| x.to_str()) == Some("lex") {
1843            out.push(path);
1844        }
1845    }
1846}
1847
1848/// `POST /v1/pkg/publish` — publish a multi-file package from a `.tar.gz`
1849/// archive containing `lex.toml` and `src/**/*.lex`.
1850fn pkg_publish_handler(state: &State, body: &[u8]) -> Response<std::io::Cursor<Vec<u8>>> {
1851    let tmp = match tempfile::TempDir::new() {
1852        Ok(t) => t,
1853        Err(e) => return error_response(500, format!("create temp dir: {e}")),
1854    };
1855    {
1856        let gz = flate2::read::GzDecoder::new(std::io::Cursor::new(body));
1857        let mut ar = tar::Archive::new(gz);
1858        if let Err(e) = ar.unpack(tmp.path()) {
1859            return error_response(400, format!("unpack archive: {e}"));
1860        }
1861    }
1862
1863    let toml_path = tmp.path().join("lex.toml");
1864    if !toml_path.exists() {
1865        return error_response(400, "archive must contain lex.toml at root");
1866    }
1867    let manifest = match Manifest::load(&toml_path) {
1868        Ok(m) => m,
1869        Err(e) => return error_response(400, format!("lex.toml: {e}")),
1870    };
1871    let (pkg_name, pkg_version) = match &manifest.package {
1872        Some(m) => (m.name.clone(), m.version.clone()),
1873        None => return error_response(400, "lex.toml must have a [package] section"),
1874    };
1875
1876    let src_dir = tmp.path().join("src");
1877    if !src_dir.exists() {
1878        return error_response(400, "archive must contain a src/ directory");
1879    }
1880    let mut lex_files: Vec<PathBuf> = Vec::new();
1881    collect_lex_files(&src_dir, &mut lex_files);
1882    if lex_files.is_empty() {
1883        return error_response(400, "no .lex files found in src/");
1884    }
1885
1886    let store = state.store.lock().unwrap();
1887    let branch = store.current_branch();
1888
1889    let mut all_ops: Vec<serde_json::Value> = Vec::new();
1890    let mut final_head_op: Option<String> = None;
1891    let mut all_function_names: Vec<String> = Vec::new();
1892
1893    // `old_fns_by_name` mirrors the branch's current function set,
1894    // GROUPED by name rather than collapsed to one entry per name. Two
1895    // files in the same package can legitimately declare a local helper
1896    // with the same name but a different signature (#818 — e.g. three
1897    // unrelated `validate` functions across `field.lex`/`schema.lex`/
1898    // `validator.lex` in a real published package). `SigId` already
1899    // disambiguates them correctly — it hashes the full signature, not
1900    // just the name — so the bug was ever collapsing multiple SigIds
1901    // sharing a name down to one `FnDecl`, silently discarding the
1902    // others and corrupting later diffs against them.
1903    //
1904    // Building this once here (rather than re-deriving it on every loop
1905    // iteration) is also what fixed #813's performance issue: `branch_head`
1906    // is an O(N)-per-call, unmemoized walk of the *entire* branch op
1907    // history, and `get_ast` is a disk fetch per live function —
1908    // O(files * (history_size + live_fn_count)) overall on a tenant with
1909    // 110k+ accumulated ops, tens of minutes even after fixing
1910    // `compute_diff`'s quadratic hashing alone.
1911    let old_head = match store.branch_head(&branch) {
1912        Ok(h) => h,
1913        Err(e) => return error_response(500, format!("branch_head: {e}")),
1914    };
1915    let mut old_fns_by_name: BTreeMap<String, Vec<lex_ast::FnDecl>> = BTreeMap::new();
1916    for fd in old_head.values()
1917        .filter_map(|stg| store.get_ast(stg).ok())
1918        .filter_map(|s| match s { lex_ast::Stage::FnDecl(fd) => Some(fd), _ => None })
1919    {
1920        old_fns_by_name.entry(fd.name.clone()).or_default().push(fd);
1921    }
1922    // Names successfully published by an EARLIER file within this same
1923    // request, grouped by name like `old_fns_by_name` (for the same
1924    // reason: two files can publish two structurally different
1925    // functions under one shared name in the same request). Kept
1926    // separate from `old_fns_by_name` so a name published here is never
1927    // swept up by the final "genuinely removed" pass below just because
1928    // no later file also happens to reference it.
1929    let mut published_this_request: BTreeMap<String, Vec<lex_ast::FnDecl>> = BTreeMap::new();
1930
1931    // A name-independent fingerprint of a function's *contract*
1932    // (effects, param types, return type, examples — everything SigId
1933    // hashes except the name). Used only to disambiguate when multiple
1934    // candidates share a bare name: if this file's own declaration
1935    // structurally matches exactly one of them, that's unambiguously
1936    // the same evolving function; otherwise it's a new, unrelated
1937    // declaration that happens to reuse a name used elsewhere.
1938    fn structural_key(fd: &lex_ast::FnDecl) -> Option<String> {
1939        let mut anon = fd.clone();
1940        anon.name = String::new();
1941        lex_ast::sig_id(&lex_ast::Stage::FnDecl(anon))
1942    }
1943
1944    // Look up (and consume) the candidate in `map[name]` that matches
1945    // `new_fd`'s identity.
1946    //
1947    // `single_candidate_always_matches` controls what happens when
1948    // there's exactly one candidate: `true` treats it as unambiguous
1949    // (preserves today's behavior for the common, non-colliding case,
1950    // including signature-changing modifications — safe only when the
1951    // candidate pool for this name is COMPLETE and won't grow further,
1952    // as `old_fns_by_name` is: built once, up front, from the whole
1953    // branch). `false` always requires an exact structural match even
1954    // with one candidate — required for `published_this_request`, whose
1955    // pool for a name grows incrementally as files are processed: "one
1956    // candidate so far" doesn't mean "no ambiguity", a not-yet-processed
1957    // file could still publish a second, differently-signed function
1958    // under the same name (exactly what happens with three files each
1959    // declaring their own local `validate`).
1960    //
1961    // Either way, several candidates always require an exact structural
1962    // match to disambiguate; no match means this is a distinct,
1963    // unrelated declaration reusing a name used elsewhere — returns
1964    // `None` rather than guessing and mis-attributing history.
1965    fn take_matching(
1966        map: &mut BTreeMap<String, Vec<lex_ast::FnDecl>>,
1967        name: &str,
1968        new_fd: &lex_ast::FnDecl,
1969        single_candidate_always_matches: bool,
1970    ) -> Option<lex_ast::FnDecl> {
1971        let candidates = map.get_mut(name)?;
1972        let idx = match candidates.len() {
1973            0 => return None,
1974            1 if single_candidate_always_matches => 0,
1975            _ => {
1976                let want = structural_key(new_fd);
1977                candidates.iter().position(|c| structural_key(c) == want)?
1978            }
1979        };
1980        let matched = candidates.remove(idx);
1981        if candidates.is_empty() {
1982            map.remove(name);
1983        }
1984        Some(matched)
1985    }
1986
1987    // Load, canonicalize, and type-check every file up front so we know
1988    // the whole package's function set before diffing any single file
1989    // against the branch.
1990    struct FileUnit {
1991        path: PathBuf,
1992        stages: Vec<lex_ast::Stage>,
1993        new_fns: BTreeMap<String, lex_ast::FnDecl>,
1994    }
1995    let mut units: Vec<FileUnit> = Vec::new();
1996    for lex_path in &lex_files {
1997        let prog = match load_program(lex_path) {
1998            Ok(p) => p,
1999            Err(e) => return error_response(400, format!("load {}: {e}", lex_path.display())),
2000        };
2001        let mut stages = canonicalize_program(&prog);
2002        if let Err(errs) = lex_types::check_and_rewrite_program(&mut stages) {
2003            return error_with_detail(
2004                422,
2005                format!("type errors in {}", lex_path.display()),
2006                serde_json::to_value(&errs).unwrap(),
2007            );
2008        }
2009        let new_fns: BTreeMap<String, lex_ast::FnDecl> = stages.iter()
2010            .filter_map(|s| match s {
2011                lex_ast::Stage::FnDecl(fd) => Some((fd.name.clone(), fd.clone())),
2012                _ => None,
2013            })
2014            .collect();
2015        units.push(FileUnit { path: lex_path.clone(), stages, new_fns });
2016    }
2017
2018    for unit in &units {
2019        let lex_path = &unit.path;
2020        let stages = &unit.stages;
2021        let new_fns = &unit.new_fns;
2022
2023        for name in new_fns.keys() {
2024            if !all_function_names.contains(name) {
2025                all_function_names.push(name.clone());
2026            }
2027        }
2028
2029        // Resolve each of this file's own declarations against a
2030        // name-and-signature-aware candidate pool: first whatever an
2031        // EARLIER file in this same request already published under
2032        // this name (several files can each own a differently-signed
2033        // function sharing a name — `published_this_request` groups by
2034        // name for exactly that reason), then the true original branch
2035        // state. See `take_matching`'s own doc comment for the
2036        // disambiguation rule.
2037        let mut old_fns_for_file: BTreeMap<String, lex_ast::FnDecl> = BTreeMap::new();
2038        for (name, new_fd) in new_fns {
2039            let resolved = take_matching(&mut published_this_request, name, new_fd, false)
2040                .or_else(|| take_matching(&mut old_fns_by_name, name, new_fd, true));
2041            if let Some(fd) = resolved {
2042                old_fns_for_file.insert(name.clone(), fd);
2043            }
2044        }
2045        let report = lex_vcs::compute_diff(&old_fns_for_file, new_fns, false);
2046
2047        let file_key = lex_path
2048            .strip_prefix(tmp.path())
2049            .unwrap_or(lex_path)
2050            .display()
2051            .to_string();
2052        let mut new_imports = lex_vcs::ImportMap::new();
2053        {
2054            let entry = new_imports.entry(file_key).or_default();
2055            for s in stages {
2056                if let lex_ast::Stage::Import(im) = s {
2057                    entry.insert(im.reference.clone());
2058                }
2059            }
2060        }
2061
2062        match store.publish_program(&branch, stages, &report, &new_imports, false) {
2063            Ok(outcome) => {
2064                let ops_json = serde_json::to_value(&outcome.ops).unwrap_or_default();
2065                if let serde_json::Value::Array(arr) = ops_json {
2066                    all_ops.extend(arr);
2067                }
2068                if let Some(h) = outcome.head_op {
2069                    final_head_op = Some(h);
2070                }
2071                // `old_fns_for_file`'s names are always a subset of
2072                // `new_fns`'s (built only from names present in this
2073                // file), so `report.removed`/`report.renamed` are
2074                // structurally always empty here — a genuine removal or
2075                // rename can only be identified once we know the WHOLE
2076                // package's declarations, handled once after this loop.
2077                // Record every one of this file's own declarations
2078                // (added, modified, *or* unchanged) as this request's
2079                // current version of that name, so a LATER file that
2080                // also touches it — whether to actually change it or
2081                // just to re-diff against it — resolves correctly
2082                // instead of finding nothing and misreporting an Add.
2083                for (name, fd) in new_fns {
2084                    published_this_request.entry(name.clone()).or_default().push(fd.clone());
2085                }
2086            }
2087            Err(lex_store::StoreError::TypeError(errs)) => {
2088                return error_with_detail(422, "type errors", serde_json::to_value(&errs).unwrap());
2089            }
2090            Err(e) => return write_error_response("publish_program", e),
2091        }
2092    }
2093
2094    // Whatever remains in `old_fns_by_name` was never claimed by any
2095    // file in this archive — genuinely removed from the package. Emit
2096    // one final removal-only publish (mirroring `pkg_yank_handler`'s own
2097    // empty-stages pattern below). Each entry carries its own
2098    // `old_sig_id` computed directly from the FnDecl being removed, so
2099    // this is safe even when several removed functions share a bare
2100    // name.
2101    //
2102    // Note: this reports a same-request rename (a name disappearing
2103    // from every file while a same-bodied function appears under a new
2104    // name) as a separate Remove + Add rather than one tracked
2105    // RenameSymbol op — a minor lineage-fidelity trade-off, not a
2106    // correctness issue, and no worse than the pre-existing behavior for
2107    // renames that already spanned files.
2108    let leftover: Vec<lex_ast::FnDecl> = old_fns_by_name.into_values().flatten().collect();
2109    if !leftover.is_empty() {
2110        let removed_report = lex_vcs::DiffReport {
2111            removed: leftover.iter().map(|fd| lex_vcs::diff_report::AddRemove {
2112                name: fd.name.clone(),
2113                signature: lex_vcs::render_signature(fd),
2114                old_sig_id: lex_ast::sig_id(&lex_ast::Stage::FnDecl(fd.clone())),
2115            }).collect(),
2116            ..Default::default()
2117        };
2118        match store.publish_program(&branch, &[], &removed_report, &lex_vcs::ImportMap::new(), false) {
2119            Ok(outcome) => {
2120                let ops_json = serde_json::to_value(&outcome.ops).unwrap_or_default();
2121                if let serde_json::Value::Array(arr) = ops_json {
2122                    all_ops.extend(arr);
2123                }
2124                if let Some(h) = outcome.head_op {
2125                    final_head_op = Some(h);
2126                }
2127            }
2128            Err(e) => return write_error_response("publish_program (removals)", e),
2129        }
2130    }
2131
2132    // Reject re-publish of the same (name, version) to keep the op log stable.
2133    if load_pkg_record(&state.root, &pkg_name, &pkg_version).is_some() {
2134        return error_response(
2135            409,
2136            format!(
2137                "package {pkg_name}@{pkg_version} already published; \
2138                 bump the version in lex.toml to publish a new release"
2139            ),
2140        );
2141    }
2142
2143    let now = SystemTime::now()
2144        .duration_since(UNIX_EPOCH)
2145        .map(|d| d.as_secs())
2146        .unwrap_or(0);
2147    let record = PkgRecord {
2148        name: pkg_name.clone(),
2149        version: pkg_version,
2150        head_op: final_head_op.clone(),
2151        published_at: now,
2152        function_names: all_function_names,
2153        ops: all_ops.clone(),
2154    };
2155    if let Err(e) = save_pkg_record(&state.root, &record, body) {
2156        return error_response(500, format!("save package index: {e}"));
2157    }
2158
2159    json_response(200, &serde_json::json!({
2160        "package": pkg_name,
2161        "ops": all_ops,
2162        "head_op": final_head_op,
2163    }))
2164}
2165
2166/// `GET /v1/pkg` — list packages published by this tenant (latest version of each).
2167fn pkg_list_handler(state: &State) -> Response<std::io::Cursor<Vec<u8>>> {
2168    let names = list_pkg_names(&state.root);
2169    let packages: Vec<serde_json::Value> = names.iter()
2170        .filter_map(|name| {
2171            let idx = load_pkg_index(&state.root, name)?;
2172            let latest = idx.latest.as_deref()?;
2173            let r = load_pkg_record(&state.root, name, latest)?;
2174            Some(serde_json::json!({
2175                "name": r.name,
2176                "version": r.version,
2177                "head_op": r.head_op,
2178                "published_at": r.published_at,
2179            }))
2180        })
2181        .collect();
2182    json_response(200, &serde_json::json!({ "packages": packages }))
2183}
2184
2185/// `GET /v1/pkg/{name}` — latest version details for a package.
2186fn pkg_get_handler(state: &State, name: &str) -> Response<std::io::Cursor<Vec<u8>>> {
2187    match load_latest_pkg_record(&state.root, name) {
2188        Some(r) => json_response(200, &serde_json::json!({
2189            "name": r.name,
2190            "version": r.version,
2191            "head_op": r.head_op,
2192            "published_at": r.published_at,
2193            "function_names": r.function_names,
2194            "ops": r.ops,
2195        })),
2196        None => error_response(404, format!("package {name:?} not found")),
2197    }
2198}
2199
2200/// `GET /v1/pkg/{name}/versions` — all published versions for a package.
2201fn pkg_versions_handler(state: &State, name: &str) -> Response<std::io::Cursor<Vec<u8>>> {
2202    match load_pkg_index(&state.root, name) {
2203        Some(idx) => json_response(200, &serde_json::json!({
2204            "name": name,
2205            "latest": idx.latest,
2206            "versions": idx.versions,
2207        })),
2208        None => error_response(404, format!("package {name:?} not found")),
2209    }
2210}
2211
2212/// `GET /v1/pkg/{name}/{version}` — specific version details.
2213fn pkg_get_version_handler(state: &State, name: &str, version: &str) -> Response<std::io::Cursor<Vec<u8>>> {
2214    match load_pkg_record(&state.root, name, version) {
2215        Some(r) => json_response(200, &serde_json::json!({
2216            "name": r.name,
2217            "version": r.version,
2218            "head_op": r.head_op,
2219            "published_at": r.published_at,
2220            "function_names": r.function_names,
2221            "ops": r.ops,
2222        })),
2223        None => error_response(404, format!("package {name:?}@{version:?} not found")),
2224    }
2225}
2226
2227/// `GET /v1/pkg/{name}/{version}/archive` — download the source tar.gz.
2228fn pkg_archive_handler(state: &State, name: &str, version: &str) -> Response<std::io::Cursor<Vec<u8>>> {
2229    let path = pkg_archive_path(&state.root, name, version);
2230    match std::fs::read(&path) {
2231        Ok(bytes) => Response::from_data(bytes)
2232            .with_status_code(200)
2233            .with_header(
2234                tiny_http::Header::from_bytes(
2235                    &b"Content-Type"[..],
2236                    &b"application/gzip"[..],
2237                )
2238                .unwrap(),
2239            ),
2240        Err(_) => error_response(404, format!("archive for {name:?}@{version:?} not found")),
2241    }
2242}
2243
2244/// `GET /v1/pkg/{name}/head` — head op for a package's latest version.
2245fn pkg_head_handler(state: &State, name: &str) -> Response<std::io::Cursor<Vec<u8>>> {
2246    match load_latest_pkg_record(&state.root, name) {
2247        Some(r) => json_response(200, &serde_json::json!({
2248            "name": r.name,
2249            "version": r.version,
2250            "head_op": r.head_op,
2251        })),
2252        None => error_response(404, format!("package {name:?} not found")),
2253    }
2254}
2255
2256/// `DELETE /v1/pkg/{name}` — retract the latest version of a package.
2257fn pkg_delete_handler(state: &State, name: &str) -> Response<std::io::Cursor<Vec<u8>>> {
2258    let record = match load_latest_pkg_record(&state.root, name) {
2259        Some(r) => r,
2260        None => return error_response(404, format!("package {name:?} not found")),
2261    };
2262
2263    let store = state.store.lock().unwrap();
2264    let branch = store.current_branch();
2265
2266    let head = match store.branch_head(&branch) {
2267        Ok(h) => h,
2268        Err(e) => return error_response(500, format!("branch_head: {e}")),
2269    };
2270
2271    // Build old_fns from this package's function names that are still on the branch.
2272    let old_fns: BTreeMap<String, lex_ast::FnDecl> = head.values()
2273        .filter_map(|stage_id| store.get_ast(stage_id).ok())
2274        .filter_map(|s| match s {
2275            lex_ast::Stage::FnDecl(fd)
2276                if record.function_names.contains(&fd.name) => Some((fd.name.clone(), fd)),
2277            _ => None,
2278        })
2279        .collect();
2280
2281    let new_fns: BTreeMap<String, lex_ast::FnDecl> = BTreeMap::new();
2282    let report = lex_vcs::compute_diff(&old_fns, &new_fns, false);
2283    let empty_imports = lex_vcs::ImportMap::new();
2284
2285    match store.publish_program(&branch, &[], &report, &empty_imports, false) {
2286        Ok(outcome) => {
2287            // Remove the version record and archive, then update the index.
2288            let ver = record.version.clone();
2289            let _ = std::fs::remove_file(pkg_version_path(&state.root, name, &ver));
2290            let _ = std::fs::remove_file(pkg_archive_path(&state.root, name, &ver));
2291            // Update index: remove this version, set latest to previous if any.
2292            if let Some(mut idx) = load_pkg_index(&state.root, name) {
2293                idx.versions.retain(|v| v.version != ver);
2294                idx.latest = idx.versions.last().map(|v| v.version.clone());
2295                if idx.versions.is_empty() {
2296                    let _ = std::fs::remove_dir_all(pkg_name_dir(&state.root, name));
2297                } else {
2298                    let bytes = serde_json::to_vec_pretty(&idx).unwrap_or_default();
2299                    let _ = std::fs::write(pkg_index_path(&state.root, name), bytes);
2300                }
2301            }
2302            json_response(200, &serde_json::json!({
2303                "deleted": name,
2304                "version": ver,
2305                "ops": outcome.ops,
2306                "head_op": outcome.head_op,
2307            }))
2308        }
2309        Err(lex_store::StoreError::TypeError(errs)) => {
2310            error_with_detail(422, "type errors", serde_json::to_value(&errs).unwrap())
2311        }
2312        Err(e) => write_error_response("retract package", e),
2313    }
2314}
2315
2316#[cfg(test)]
2317mod policy_ceiling_tests {
2318    use super::*;
2319    use lex_runtime::Policy;
2320    use std::path::PathBuf;
2321
2322    /// A maximally-permissive policy of the kind a malicious caller
2323    /// would put in a `/v1/run` body: every dangerous effect plus
2324    /// fs over `/`.
2325    fn permissive_request() -> Policy {
2326        Policy {
2327            allow_effects: ["io", "fs_read", "fs_write", "net", "proc"]
2328                .iter()
2329                .map(|s| s.to_string())
2330                .collect(),
2331            allow_fs_read: vec![PathBuf::from("/")],
2332            allow_fs_write: vec![PathBuf::from("/")],
2333            allow_net_host: Vec::new(),
2334            allow_proc: Vec::new(),
2335            allow_approval: Vec::new(),
2336            budget: None,
2337        }
2338    }
2339
2340    #[test]
2341    fn ceiling_drops_effects_the_caller_was_not_granted() {
2342        let ceiling = Policy {
2343            allow_effects: ["io", "time"].iter().map(|s| s.to_string()).collect(),
2344            ..Policy::default()
2345        };
2346        let got = clamp_policy(permissive_request(), &ceiling);
2347        assert!(got.allow_effects.contains("io"));
2348        assert!(!got.allow_effects.contains("proc"), "proc must not survive a ceiling without it");
2349        assert!(!got.allow_effects.contains("fs_write"));
2350        assert!(!got.allow_effects.contains("net"));
2351        // `time` is in the ceiling but not the request → intersection drops it.
2352        assert!(!got.allow_effects.contains("time"));
2353    }
2354
2355    #[test]
2356    fn ceiling_scopes_override_caller_scopes() {
2357        let ceiling = Policy {
2358            allow_effects: ["fs_read"].iter().map(|s| s.to_string()).collect(),
2359            allow_fs_read: vec![PathBuf::from("/srv/tenant")],
2360            ..Policy::default()
2361        };
2362        let got = clamp_policy(permissive_request(), &ceiling);
2363        // Caller asked for "/" but only the ceiling's scope survives —
2364        // an empty/wider caller list can never widen the ceiling.
2365        assert_eq!(got.allow_fs_read, vec![PathBuf::from("/srv/tenant")]);
2366        assert!(got.allow_fs_write.is_empty());
2367        assert!(got.allow_proc.is_empty());
2368        assert!(got.allow_net_host.is_empty());
2369    }
2370
2371    #[test]
2372    fn ceiling_caps_budget_and_prefers_the_smaller() {
2373        // Caller wants unlimited; ceiling caps it.
2374        let mut req = permissive_request();
2375        req.budget = None;
2376        let ceiling = Policy { budget: Some(1_000), ..Policy::default() };
2377        assert_eq!(clamp_policy(req, &ceiling).budget, Some(1_000));
2378
2379        // Caller asks for less than the ceiling → keep the caller's.
2380        let mut req2 = permissive_request();
2381        req2.budget = Some(50);
2382        let ceiling2 = Policy { budget: Some(1_000), ..Policy::default() };
2383        assert_eq!(clamp_policy(req2, &ceiling2).budget, Some(50));
2384    }
2385
2386    #[test]
2387    fn empty_ceiling_is_pure_only() {
2388        let got = clamp_policy(permissive_request(), &Policy::default());
2389        assert!(got.allow_effects.is_empty(), "an empty ceiling grants nothing");
2390        assert!(got.allow_proc.is_empty());
2391        assert!(got.allow_fs_write.is_empty());
2392    }
2393}
2394
2395#[cfg(test)]
2396mod public_read_tests {
2397    use super::*;
2398
2399    /// Write a minimal package (index + per-version record + an archive
2400    /// blob) straight into a temp store, bypassing the publish pipeline.
2401    fn seed_pkg(root: &std::path::Path, name: &str, version: &str) {
2402        let record = PkgRecord {
2403            name: name.to_string(),
2404            version: version.to_string(),
2405            head_op: Some(format!("op-{name}")),
2406            published_at: 1,
2407            function_names: vec![format!("{name}.f")],
2408            ops: vec![],
2409        };
2410        save_pkg_record(root, &record, format!("ARCHIVE:{name}@{version}").as_bytes())
2411            .expect("seed package");
2412    }
2413
2414    #[test]
2415    fn new_package_defaults_to_private() {
2416        let tmp = tempfile::TempDir::new().unwrap();
2417        seed_pkg(tmp.path(), "lex-schema", "0.9.2");
2418        assert!(!pkg_is_public(tmp.path(), "lex-schema"));
2419        // Unknown packages are also "not public" — never distinguished.
2420        assert!(!pkg_is_public(tmp.path(), "does-not-exist"));
2421    }
2422
2423    #[test]
2424    fn set_visibility_round_trips_and_index_persists() {
2425        let tmp = tempfile::TempDir::new().unwrap();
2426        let state = State::open(tmp.path().to_path_buf()).unwrap();
2427        seed_pkg(tmp.path(), "lex-schema", "0.9.2");
2428
2429        let _ = pkg_set_visibility_handler(&state, "lex-schema", r#"{"visibility":"public"}"#);
2430        assert!(pkg_is_public(tmp.path(), "lex-schema"));
2431        // The version list survives the index rewrite (we don't clobber it).
2432        let idx = load_pkg_index(tmp.path(), "lex-schema").unwrap();
2433        assert_eq!(idx.latest.as_deref(), Some("0.9.2"));
2434        assert_eq!(idx.versions.len(), 1);
2435
2436        let _ = pkg_set_visibility_handler(&state, "lex-schema", r#"{"visibility":"private"}"#);
2437        assert!(!pkg_is_public(tmp.path(), "lex-schema"));
2438    }
2439
2440    #[test]
2441    fn set_visibility_on_unknown_package_is_a_noop() {
2442        let tmp = tempfile::TempDir::new().unwrap();
2443        let state = State::open(tmp.path().to_path_buf()).unwrap();
2444        // No package seeded → handler returns 404 and writes nothing.
2445        let _ = pkg_set_visibility_handler(&state, "ghost", r#"{"visibility":"public"}"#);
2446        assert!(load_pkg_index(tmp.path(), "ghost").is_none());
2447    }
2448
2449    #[test]
2450    fn public_listing_omits_private_packages() {
2451        let tmp = tempfile::TempDir::new().unwrap();
2452        let state = State::open(tmp.path().to_path_buf()).unwrap();
2453        seed_pkg(tmp.path(), "pub-pkg", "1.0.0");
2454        seed_pkg(tmp.path(), "priv-pkg", "1.0.0");
2455        let _ = pkg_set_visibility_handler(&state, "pub-pkg", r#"{"visibility":"public"}"#);
2456
2457        let names = public_pkg_names(tmp.path());
2458        assert_eq!(names, vec!["pub-pkg".to_string()]);
2459    }
2460
2461    #[test]
2462    fn resolve_public_maps_routes() {
2463        let get = Method::Get;
2464        assert_eq!(resolve_public(&get, "").unwrap(), PublicTarget::List);
2465        assert_eq!(resolve_public(&get, "/").unwrap(), PublicTarget::List);
2466        assert_eq!(
2467            resolve_public(&get, "/lex-schema").unwrap(),
2468            PublicTarget::Latest("lex-schema".into())
2469        );
2470        assert_eq!(
2471            resolve_public(&get, "/lex-schema/versions").unwrap(),
2472            PublicTarget::Versions("lex-schema".into())
2473        );
2474        assert_eq!(
2475            resolve_public(&get, "/lex-schema/head").unwrap(),
2476            PublicTarget::Head("lex-schema".into())
2477        );
2478        assert_eq!(
2479            resolve_public(&get, "/lex-schema/0.9.2").unwrap(),
2480            PublicTarget::Version("lex-schema".into(), "0.9.2".into())
2481        );
2482        assert_eq!(
2483            resolve_public(&get, "/lex-schema/0.9.2/archive").unwrap(),
2484            PublicTarget::Archive("lex-schema".into(), "0.9.2".into())
2485        );
2486    }
2487
2488    #[test]
2489    fn resolve_public_rejects_bad_method_and_traversal() {
2490        // Non-GET → 405.
2491        assert_eq!(resolve_public(&Method::Put, "/lex-schema"), Err(405));
2492        assert_eq!(resolve_public(&Method::Post, "").err(), Some(405));
2493        // Path traversal / invalid segments → 404, never a filesystem touch.
2494        assert_eq!(resolve_public(&Method::Get, "/.."), Err(404));
2495        assert_eq!(resolve_public(&Method::Get, "/lex-schema/../etc"), Err(404));
2496        assert_eq!(resolve_public(&Method::Get, "/a/b/c/d"), Err(404));
2497        // Slashes elsewhere can't smuggle a deep path: each segment is checked.
2498        assert!(resolve_public(&Method::Get, "/lex schema").is_err());
2499    }
2500}