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