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