Skip to main content

lex_api/
handlers.rs

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