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