Skip to main content

mlua_swarm_server/
worker.rs

1//! HTTP `/v1/worker/*` endpoints (SubAgent self-fetch path).
2//!
3//! # 7-Entry pointer #6 (Output Event design)
4//!
5//! **This endpoint accesses `OutputStore` directly and does NOT go through the engine.**
6//! It is one of the seven entry points enumerated in project `CLAUDE.md` §"Output Event
7//! Design SoT". For the canonical description, see the crate root doc of
8//! `mlua-swarm-output-store` (`cargo doc -p mlua-swarm-output-store`).
9//!
10//! # Path
11//!
12//! A thin-payload path where a SubAgent (= worker process launched by a MainAI) uses
13//! the capability token it received via WS Spawn to self-fetch its prompt and
14//! submit its result — putting the token in `Authorization: Bearer <encoded CapToken>`.
15//!
16//! ## Routes
17//!
18//! - `GET /v1/worker/prompt?task_id=<tid>` — via `engine.fetch_worker_payload`,
19//!   returns `{task_id, attempt, agent, system?, prompt, context?}`.
20//!   `context.steps` (`projection-adapter` ST5, [`assemble_step_pointers`])
21//!   is assembled fresh on every fetch: a `ContextPolicy.steps`-filtered
22//!   pointer list to preceding steps' OUTPUT, resolved through
23//!   `crate::projection::McpQueryAdapter`'s Data-plane + `result_ref`
24//!   enumeration — no separate MCP tool call needed to discover a prior
25//!   step's OUTPUT.
26//! - `POST /v1/worker/result` with body `{task_id, value, ok}` — appends one `Final`
27//!   to the output tail via `engine.submit_output(Final)` (= the canonical path
28//!   through which the dispatch layer decides Pass/Blocked) and updates
29//!   `task.last_result` via `engine.post_result`.
30//! - `POST /v1/worker/artifact?name=<name>` (GH #36 ST1) — stages one named
31//!   part per POST via `engine.stage_worker_artifact_trusted`. Completing the
32//!   attempt is still `POST /v1/worker/submit` / `/v1/worker/result` — this
33//!   route only stages; the dispatch layer's Final-pull folds every staged
34//!   part into `{"out": <final>, "parts": {<name>: <value>, ...}}`. At
35//!   staging time the submit-time projection sink also materializes the part
36//!   raw to `<ctx-dir>/<name>` (the IN file the next Agent step reads;
37//!   fail-open skip when no `work_dir` / `project_root` resolves).
38//! - `GET /v1/worker/prompt/system?task_id=<tid>&attempt=<n>` (GH #31) —
39//!   raw baked `system` bytes for `(task_id, attempt)`, the `Http`-mode
40//!   fetch target for `system_ref.uri`. Same Bearer flow as
41//!   `/v1/worker/prompt`; body is `text/plain`, not JSON.
42//! - `GET /v1/agents/:name/render-size` (GH #31) — no Bearer required, same
43//!   trust tier as `GET /v1/blueprints/:id/head`. Live per-agent most-recently
44//!   observed render size, backing `bp_doctor`'s post-render check.
45//! - `POST /v1/worker/degradation` (GH #32) — structured JSON `{tool, error,
46//!   fallback, note?}`, same Bearer flow as [`worker_submit`]. An
47//!   **independent channel**: entries are appended to `RunRecord.degradations`
48//!   via `RunStore::append_degradation` directly and never touch
49//!   `OutputStore` / the fold path (Crux invariant 2 — a degradation must
50//!   never surface as step OUTPUT). `step_ref` / `attempt` / `at` are
51//!   server-injected, never trusted from the client. Silent `204` (no
52//!   append) when the dispatch task carries no Run linkage — same
53//!   fail-open contract as [`reject_if_run_terminal`]'s own resolution
54//!   steps, since a pre-run-tracking dispatch has nowhere to record a
55//!   degradation and that must not become a client-visible error.
56//!
57//! ## Bearer authentication
58//!
59//! The Bearer value is the string produced by `CapToken::encode()` (= URL-safe
60//! base64 of serde_json). The server decodes it with `CapToken::decode` and then,
61//! inside the engine, verifies HMAC sig + role × verb gate + TTL via
62//! `verify_token_for_task` (= self-contained capability token; no server-side
63//! store lookup required).
64//!
65//! Tokens are minted during the "2) mint outside the lock" phase of
66//! `engine.dispatch_attempt` (`Role::Worker`, 600s TTL, `scopes=["*"]`).
67//! The verb gate covers `FetchPrompt` / `EmitOutput` / `PostResult` — the worker
68//! leaf capability set (`crate::types::WORKER_LEAF_VERBS`).
69
70use axum::{
71    extract::{Query, State},
72    http::{header, header::AUTHORIZATION, HeaderMap, StatusCode},
73    Json,
74};
75use mlua_swarm::core::agent_context::StepPointer;
76use mlua_swarm::core::state::SubmitOutcome;
77use mlua_swarm::core::step_naming::StepNaming;
78use mlua_swarm::store::run::{DegradationEntry, RunStatus, RunStoreError};
79use mlua_swarm::{CapToken, ContentRef, EngineError, OutputEvent, RunId, StepId, WorkerPayload};
80use mlua_swarm_schema::{ContextPolicy, VerdictChannel};
81use serde::Deserialize;
82use serde_json::Value;
83
84use crate::projection::McpQueryAdapter;
85use crate::{ApiError, AppState};
86
87/// Query params for `GET /v1/worker/prompt`.
88#[derive(Debug, Deserialize)]
89pub struct PromptQuery {
90    /// Task the fetched prompt belongs to; cross-checked against the Bearer
91    /// handle/token. Typed [`StepId`] since issue #14 — the wire shape stays
92    /// a plain string; a bad prefix is rejected at deserialize.
93    pub task_id: StepId,
94}
95
96/// `GET /v1/worker/prompt?task_id=<tid>`. Bearer = encoded `CapToken` or short `wh-` handle.
97/// Thin HTTP wrapper over `engine.fetch_worker_payload` / `fetch_worker_payload_trusted`.
98/// Short-handle path (recommended for SubAgents): handle → task_id
99/// cross-check → trusted fetch.
100/// Full-`CapToken` path: token decode → verify → fetch.
101pub async fn worker_prompt(
102    State(state): State<AppState>,
103    headers: HeaderMap,
104    Query(q): Query<PromptQuery>,
105) -> Result<Json<WorkerPayload>, ApiError> {
106    let task_id = q.task_id;
107    let bearer = extract_bearer_raw(&headers)?;
108    let mut payload = if let Some(handle) = parse_worker_handle(&bearer) {
109        // Short-handle path: verify handle → task_id (security: confirm the handle is bound to this task).
110        let resolved = state
111            .engine
112            .task_id_from_handle(handle)
113            .await
114            .map_err(map_handle_lookup_err)?;
115        if resolved != task_id {
116            return Err(ApiError::bad_request(format!(
117                "handle {handle} is bound to task {resolved}, not {task_id}"
118            )));
119        }
120        state
121            .engine
122            .fetch_worker_payload_trusted(&task_id)
123            .await
124            .map_err(|e| ApiError::engine(format!("fetch_worker_payload_trusted: {e}")))?
125    } else {
126        // Full CapToken path (the alternate Bearer form).
127        let token = CapToken::decode(bearer.trim())
128            .map_err(|e| ApiError::bad_request(format!("invalid token: {e}")))?;
129        state
130            .engine
131            .fetch_worker_payload(&token, &task_id)
132            .await
133            .map_err(|e| ApiError::engine(format!("fetch_worker_payload: {e}")))?
134    };
135    assemble_step_pointers(&state, &mut payload).await;
136    Ok(Json(payload))
137}
138
139/// Assembles `payload.context.steps` — the `ContextPolicy.steps`-filtered
140/// pointer list to preceding steps' OUTPUT (`projection-adapter` ST5's
141/// Worker axis; see `mlua_swarm::core::agent_context`'s module doc).
142/// Resolved fresh on every fetch (not baked at spawn time), so a step
143/// submitted after this agent spawned — but before it fetches its prompt
144/// — is still visible.
145///
146/// GH #23 subtask-3: `resolved_steps` (from
147/// `McpQueryAdapter::list_steps_by_run_id`) always reports the CANONICAL
148/// name (see `crate::projection`'s module doc), so both the self-exclusion
149/// check and the `ContextPolicy` match are done against canonical names —
150/// `payload.agent` (the raw `Step.ref` this fetching agent was dispatched
151/// under) is canonicalized via `Engine::step_naming_for(&payload.task_id)`
152/// (the FETCHING agent's own dispatch id — the same `StepNaming` `Arc`
153/// every step of this Blueprint launch shares, see [`StepNaming`]'s module
154/// doc), and `policy.allows_step` itself is left untouched (schema crate
155/// stays name-agnostic) — [`allows_step_canonical`] is the caller-side seam
156/// that resolves each policy-declared name through the table before
157/// comparing.
158///
159/// No-op (`context.steps` stays empty) when: the payload carries no
160/// `context` at all; the context has no `run_id` (a spawn that never
161/// threaded one through — pre-run-tracking callers, or a spawner stack
162/// without the Run-tracking layer); or the addressed Run cannot be
163/// resolved. All three are fail-open, matching this crate's other
164/// best-effort projection hooks (a missing pointer list must never turn a
165/// would-have-succeeded fetch into a failure).
166async fn assemble_step_pointers(state: &AppState, payload: &mut WorkerPayload) {
167    let Some(context) = payload.context.as_mut() else {
168        return;
169    };
170    let Some(run_id_str) = context.run_id.clone() else {
171        return;
172    };
173    let Ok(run_id) = RunId::parse(run_id_str) else {
174        return;
175    };
176
177    let adapter = McpQueryAdapter::new(
178        state.data_store.clone(),
179        state.run_store.clone(),
180        state.engine.clone(),
181    );
182    let Ok((run, resolved_steps)) = adapter.list_steps_by_run_id(&run_id).await else {
183        return;
184    };
185
186    let naming = state.engine.step_naming_for(&payload.task_id).await;
187    let policy = state
188        .engine
189        .context_policy_for(&payload.task_id, payload.attempt)
190        .await;
191    let self_canonical = naming
192        .as_deref()
193        .and_then(|n| n.canonical_of_producer(&payload.agent))
194        .map(str::to_string)
195        .unwrap_or_else(|| payload.agent.clone());
196
197    let mut pointers = Vec::new();
198    for step in &resolved_steps {
199        if step.name == self_canonical
200            || !allows_step_canonical(&policy, naming.as_deref(), &step.name)
201        {
202            continue;
203        }
204        if let Some((size_bytes, file_path, content_url, sha256)) =
205            crate::projection::resolve_step_pointer_fields(state, &run, step).await
206        {
207            pointers.push(StepPointer {
208                name: step.name.clone(),
209                size_bytes,
210                file_path,
211                content_url,
212                sha256,
213            });
214        }
215    }
216    context.steps = pointers;
217}
218
219/// GH #23 subtask-3: caller-side canonical/alias expansion for
220/// `ContextPolicy.allows_step` — same precedence as
221/// `ContextPolicy::allows_step` itself (`steps_exclude` wins; `steps:
222/// None` = pass-all, `Some(list)` = named-only), but each
223/// policy-declared name is resolved through the Blueprint's `StepNaming`
224/// table before comparison, so a Blueprint author's `steps: [...]` entry
225/// naming either the canonical projection name OR any alias (`Step.ref` /
226/// the `out` ctx-path's top-level segment) matches the same step.
227/// `ContextPolicy::allows_step` (schema crate) is untouched — this is the
228/// GH #23 seam, kept out of the name-agnostic schema type. `naming: None`
229/// degrades to a literal string comparison, byte-identical to
230/// `ContextPolicy::allows_step` itself (defensive-only fallback, matching
231/// `crate::projection::McpQueryAdapter::step_naming_for_run`'s own
232/// contract).
233fn allows_step_canonical(
234    policy: &ContextPolicy,
235    naming: Option<&StepNaming>,
236    canonical_name: &str,
237) -> bool {
238    let resolves_to = |raw: &str| -> bool {
239        match naming {
240            Some(n) => n
241                .resolve(raw)
242                .map(|c| c == canonical_name)
243                .unwrap_or(raw == canonical_name),
244            None => raw == canonical_name,
245        }
246    };
247    if policy
248        .steps_exclude
249        .iter()
250        .any(|excluded| resolves_to(excluded))
251    {
252        return false;
253    }
254    match &policy.steps {
255        None => true,
256        Some(list) => list.iter().any(|included| resolves_to(included)),
257    }
258}
259
260/// Body for `POST /v1/worker/result`.
261#[derive(Debug, Deserialize)]
262pub struct WorkerResultReq {
263    /// Task this result belongs to (looked up together with the Bearer
264    /// token). Typed [`StepId`] since issue #14 (see [`PromptQuery`]).
265    pub task_id: StepId,
266    /// `WorkerResult.value` (= the value returned by the Operator: LLM inference result or tool execution result).
267    pub value: Value,
268    /// `WorkerResult.ok`. `false` makes the dispatch path decide Blocked
269    /// (= same semantics as `OutputEvent::Final { ok: false, .. }` from a
270    /// `SpawnerAdapter`). Defaults to `true`.
271    #[serde(default = "default_ok_true")]
272    pub ok: bool,
273    /// Optional explicit attempt. Normally omitted (= the server looks up `task.attempt`).
274    /// A carry for race-condition tests that need to write to a fixed attempt.
275    #[serde(default)]
276    pub attempt: Option<u32>,
277}
278
279fn default_ok_true() -> bool {
280    true
281}
282
283/// `POST /v1/worker/result`. Bearer = encoded `CapToken`.
284/// Fires `engine.submit_output(Final)` + `engine.post_result`.
285pub async fn worker_result(
286    State(state): State<AppState>,
287    headers: HeaderMap,
288    Json(req): Json<WorkerResultReq>,
289) -> Result<StatusCode, ApiError> {
290    let token = decode_worker_bearer(&headers)?;
291    let task_id = req.task_id.clone();
292
293    // Use body-explicit attempt if provided; otherwise the current task.attempt.
294    let attempt = match req.attempt {
295        Some(n) => n,
296        None => state
297            .engine
298            .task_attempt(&task_id)
299            .await
300            .map_err(|e| ApiError::engine(format!("task_attempt: {e}")))?,
301    };
302
303    let event = OutputEvent::Final {
304        content: ContentRef::Inline {
305            value: req.value.clone(),
306        },
307        ok: req.ok,
308    };
309    // GH #51: completion-time verdict-contract enforcement now runs
310    // inside `Engine::submit_output` itself (see
311    // `map_completion_result`'s doc) — this route previously called no
312    // gate at all.
313    map_completion_result(
314        state
315            .engine
316            .submit_output(&token, &task_id, attempt, event)
317            .await,
318        "submit_output",
319    )?;
320    state
321        .engine
322        .post_result(&token, &task_id, req.value)
323        .await
324        .map_err(|e| ApiError::engine(format!("post_result: {e}")))?;
325    Ok(StatusCode::NO_CONTENT)
326}
327
328/// Body-level protocol prefix recognized by [`worker_submit`] and
329/// [`worker_artifact`] (GH #42). When the trimmed request body starts with
330/// this sentinel, the rest is treated as an absolute path; the file is
331/// read and its contents replace the submitted body. Non-sentinel bodies
332/// are unchanged.
333///
334/// See [`resolve_file_sentinel`] for the resolution rules and guards.
335const FILE_SENTINEL_PREFIX: &str = "@file:";
336
337/// Byte ceiling on the resolved-file body, matching the HTTP
338/// `DefaultBodyLimit` applied to inline bodies at the router (2 MiB, see
339/// the `/v1/worker/submit` layer in `crate::app_router`). Sentinel
340/// bodies bypass that axum body layer (the request itself is small), so
341/// the guard is checked in [`resolve_file_sentinel`] instead.
342const FILE_SENTINEL_MAX_BYTES: u64 = 2 * 1024 * 1024;
343
344/// `AgentContextView.extra` key that opts a step into `@file:` sentinel
345/// resolution (GH #43). Declared through the GH #21 meta channels
346/// (`Blueprint.metas` / `AgentMeta.ctx` / step-level `$step_meta`) and
347/// folded into the view at spawn time by `AgentContextMiddleware`.
348///
349/// Default-deny: absent, or any value other than the strict boolean
350/// `true` (a string `"true"` does not count), rejects the sentinel with
351/// `400`. The v0.9.x line has no sentinel at all, so deny-by-default is
352/// the released-behavior-compatible default; a step whose output
353/// contract legitimately needs file submission opts in with one
354/// declaration.
355const FILE_SENTINEL_ALLOW_KEY: &str = "allow_file_submit";
356
357/// `AgentContextView.extra` key carrying a step's declared submit format.
358/// Declared through the same GH #21 meta channels as
359/// [`FILE_SENTINEL_ALLOW_KEY`] (`Blueprint.metas` / `AgentMeta.ctx` /
360/// step-level `$step_meta`) and folded into the view at spawn time by
361/// `AgentContextMiddleware`. Same string as the engine-side fold's key
362/// (`mlua_swarm::core::engine::SUBMIT_FORMAT_KEY`) — this route and the
363/// fold are the two halves of one contract.
364///
365/// Absent (the overwhelming majority of steps): this route stages the
366/// body as `Value::String`, byte-for-byte — the engine's Final-pull fold
367/// then applies the default LENIENT container parse (a body that parses
368/// as a JSON object / array folds structured into the flow ctx; scalars
369/// and prose stay strings — see `mlua_swarm::core::engine::FoldParse`).
370/// The recognized values are [`SUBMIT_FORMAT_JSON`] (strict parse-or-422
371/// here at submit time) and [`SUBMIT_FORMAT_TEXT`] (fold-side opt-out);
372/// see [`resolve_submit_value`].
373const SUBMIT_FORMAT_KEY: &str = mlua_swarm::core::engine::SUBMIT_FORMAT_KEY;
374
375/// Strict [`SUBMIT_FORMAT_KEY`] value: parse the final submit body as
376/// JSON — any JSON value, scalars included, unlike the fold's
377/// containers-only lenient default — and fold the parsed [`Value`], with
378/// an unparseable body rejected `422` at submit time. The opt-in for "the
379/// worker PROMISES JSON" (fail loud, retry-able) rather than "parse it if
380/// it happens to be JSON".
381const SUBMIT_FORMAT_JSON: &str = "json";
382
383/// Opt-out [`SUBMIT_FORMAT_KEY`] value: the body stages as a string here
384/// AND the engine-side fold skips its lenient container parse for the
385/// step (final body and staged parts alike), so a JSON-container-looking
386/// text reaches the flow ctx as raw text
387/// (`mlua_swarm::core::engine::SUBMIT_FORMAT_TEXT`).
388const SUBMIT_FORMAT_TEXT: &str = mlua_swarm::core::engine::SUBMIT_FORMAT_TEXT;
389
390/// How many leading characters of an unparseable body the `422` echoes
391/// back, so the failure is diagnosable from the HTTP response alone
392/// without dumping a multi-KB payload into the error message.
393const SUBMIT_FORMAT_PREVIEW_CHARS: usize = 80;
394
395/// Resolves the `@file:<abs-path>` sentinel (GH #42) when present at the
396/// start of `body_str`. When absent, returns `body_str` unchanged — this
397/// is the byte-for-byte compatible path for all pre-#42 workers.
398///
399/// # Sentinel form
400///
401/// The trimmed body is `@file:<abs-path>` on a single line — a worker
402/// materializes the large payload to a file under its task's `work_dir`
403/// with its existing `Write` capability, then submits the sentinel body
404/// instead of streaming the payload back through the LLM.
405///
406/// # Guards
407///
408/// - Empty / multi-line path → `400`.
409/// - Relative path → `400` (the allowlist works only in
410///   canonicalized-absolute form).
411/// - `AgentContextView` not materialized for `(task_id, attempt)` → `400`
412///   (spawn must have run through `AgentContextMiddleware`; without a
413///   view there is no allowlist root to check against).
414/// - `view.extra[`[`FILE_SENTINEL_ALLOW_KEY`]`]` is not boolean `true` →
415///   `400` (GH #43 — file submission is opt-in per step; default-deny).
416/// - `view.work_dir` is `None` → `400`.
417/// - Canonicalized path is not under canonicalized `work_dir` → `400`
418///   (blocks `..`-escapes and symlinks pointing outside the allowlist).
419/// - File does not exist → `404`.
420/// - File size > [`FILE_SENTINEL_MAX_BYTES`] → `413`.
421/// - Any other I/O / canonicalize error → `500`.
422///
423/// The resolved contents are `trim_end()`-ed to match the inline path's
424/// own trailing-whitespace strip, so the downstream `Value::String` is
425/// observationally identical whether the body arrived inline or via
426/// sentinel.
427async fn resolve_file_sentinel(
428    state: &AppState,
429    task_id: &StepId,
430    attempt: u32,
431    body_str: String,
432) -> Result<String, ApiError> {
433    let Some(rest) = body_str.strip_prefix(FILE_SENTINEL_PREFIX) else {
434        return Ok(body_str);
435    };
436    let path_str = rest.trim();
437    if path_str.is_empty() {
438        return Err(ApiError::bad_request(
439            "@file: sentinel: empty path".to_string(),
440        ));
441    }
442    if path_str.contains('\n') || path_str.contains('\r') {
443        return Err(ApiError::bad_request(
444            "@file: sentinel: path must be a single line".to_string(),
445        ));
446    }
447    let path = std::path::Path::new(path_str);
448    if !path.is_absolute() {
449        return Err(ApiError::bad_request(format!(
450            "@file: sentinel: path must be absolute (got {path_str:?})"
451        )));
452    }
453    let view = state
454        .engine
455        .agent_context_for(task_id, attempt)
456        .await
457        .ok_or_else(|| {
458            ApiError::bad_request(
459                "@file: sentinel: no AgentContextView for this task/attempt \
460                 (spawn must run through AgentContextMiddleware to enable \
461                 sentinel resolution)"
462                    .to_string(),
463            )
464        })?;
465    // GH #43: file submission is opt-in per step (default-deny). Strict
466    // boolean `true` only — folded from the Blueprint meta channels by
467    // `AgentContextMiddleware` at spawn time.
468    if view.extra.get(FILE_SENTINEL_ALLOW_KEY) != Some(&Value::Bool(true)) {
469        return Err(ApiError::bad_request(format!(
470            "@file: sentinel: file submission is not allowed for this step \
471             (declare `{FILE_SENTINEL_ALLOW_KEY}: true` via `$step_meta` / \
472             `AgentMeta.ctx` / `Blueprint.metas`; strict boolean `true` \
473             required)"
474        )));
475    }
476    let work_dir = view.work_dir.ok_or_else(|| {
477        ApiError::bad_request("@file: sentinel: task has no resolved work_dir".to_string())
478    })?;
479    let work_dir_canon = tokio::fs::canonicalize(&work_dir).await.map_err(|e| {
480        ApiError::engine(format!(
481            "@file: sentinel: canonicalize work_dir {work_dir:?}: {e}"
482        ))
483    })?;
484    let path_canon = match tokio::fs::canonicalize(path).await {
485        Ok(p) => p,
486        Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
487            return Err(ApiError::not_found(format!(
488                "@file: sentinel: file not found: {path_str}"
489            )));
490        }
491        Err(e) => {
492            return Err(ApiError::engine(format!(
493                "@file: sentinel: canonicalize {path_str:?}: {e}"
494            )));
495        }
496    };
497    if !path_canon.starts_with(&work_dir_canon) {
498        return Err(ApiError::bad_request(format!(
499            "@file: sentinel: path {} is not under work_dir {} (canonicalized: {} vs {})",
500            path_str,
501            work_dir,
502            path_canon.display(),
503            work_dir_canon.display(),
504        )));
505    }
506    let meta = tokio::fs::metadata(&path_canon)
507        .await
508        .map_err(|e| ApiError::engine(format!("@file: sentinel: metadata {path_str:?}: {e}")))?;
509    if meta.len() > FILE_SENTINEL_MAX_BYTES {
510        return Err(ApiError::payload_too_large(format!(
511            "@file: sentinel: file size {} exceeds limit {}",
512            meta.len(),
513            FILE_SENTINEL_MAX_BYTES
514        )));
515    }
516    let bytes = tokio::fs::read(&path_canon)
517        .await
518        .map_err(|e| ApiError::engine(format!("@file: sentinel: read {path_str:?}: {e}")))?;
519    // Match the `trim_end()` the inline path applies (see `worker_submit`).
520    Ok(String::from_utf8_lossy(&bytes).trim_end().to_string())
521}
522
523/// Resolves the final submit body into the [`Value`] folded into the flow
524/// ctx, honoring the [`SUBMIT_FORMAT_KEY`] opt-in.
525///
526/// Called by [`worker_submit`] AFTER [`resolve_file_sentinel`], so a
527/// declared step may combine both: the sentinel resolves the file first
528/// and the parse then applies to the file's contents, exactly as if the
529/// same bytes had been posted inline.
530///
531/// Outcomes:
532///
533/// - No `AgentContextView` for `(task_id, attempt)`, or no
534///   [`SUBMIT_FORMAT_KEY`] in `view.extra` → `Value::String(body_str)`,
535///   staged unchanged down to the byte. (The engine's Final-pull fold
536///   later applies its default lenient container parse to this string —
537///   see [`SUBMIT_FORMAT_KEY`]'s doc; this route itself never sniffs.)
538/// - `submit_format: "json"` and the body parses → the parsed [`Value`]
539///   (object, array, or any other JSON value — scalars included, unlike
540///   the fold's containers-only lenient default).
541/// - `submit_format: "json"` and the body does NOT parse → `422`
542///   (declared-strict, the same posture as the verdict contract: a
543///   declared output contract the worker breaks is rejected rather than
544///   silently degraded). The message names the agent and echoes the
545///   first [`SUBMIT_FORMAT_PREVIEW_CHARS`] characters of the body.
546/// - `submit_format: "text"` → `Value::String(body_str)`, no warn. The
547///   declaration's real effect lives in the engine fold
548///   (`FoldParse::Raw`); here it is simply a recognized no-op.
549/// - Any other declared value (`"yaml"`, `true`, a typo) → the body folds
550///   as `Value::String` and a `tracing::warn!` records the unrecognized
551///   declaration. An unknown value is not a client error: the strict lane
552///   belongs to the recognized formats, and a future kind-agnostic output
553///   contract is the place to type this key.
554///
555/// The verdict contract is deliberately NOT consulted here — the
556/// completion-time check lives inside
557/// `Engine::submit_worker_result_trusted` and runs on the value this
558/// function returns, so an undeclared agent's bare token reaches it as
559/// the same `Value::String` as before.
560async fn resolve_submit_value(
561    state: &AppState,
562    task_id: &StepId,
563    attempt: u32,
564    body_str: String,
565) -> Result<Value, ApiError> {
566    let declared = state
567        .engine
568        .agent_context_for(task_id, attempt)
569        .await
570        .and_then(|view| {
571            view.extra
572                .get(SUBMIT_FORMAT_KEY)
573                .cloned()
574                .map(|declared| (view.agent, declared))
575        });
576    let Some((agent, declared)) = declared else {
577        return Ok(Value::String(body_str));
578    };
579    match declared.as_str() {
580        Some(SUBMIT_FORMAT_JSON) => serde_json::from_str::<Value>(&body_str).map_err(|e| {
581            let preview: String = body_str.chars().take(SUBMIT_FORMAT_PREVIEW_CHARS).collect();
582            ApiError::unprocessable(format!(
583                "submit_format violation: agent {agent:?} declared \
584                     `{SUBMIT_FORMAT_KEY}: {SUBMIT_FORMAT_JSON:?}`, but the submitted body is \
585                     not valid JSON: {e} (body starts with: {preview:?})"
586            ))
587        }),
588        Some(SUBMIT_FORMAT_TEXT) => Ok(Value::String(body_str)),
589        _ => {
590            tracing::warn!(
591                agent = %agent,
592                declared = %declared,
593                "unknown `{SUBMIT_FORMAT_KEY}` value; folding the body as a string \
594                 (recognized values: {SUBMIT_FORMAT_JSON:?}, {SUBMIT_FORMAT_TEXT:?})"
595            );
596            Ok(Value::String(body_str))
597        }
598    }
599}
600
601/// GH #50 (Subtask 2) — submit-time verdict contract gate, shared by
602/// [`worker_submit`] (`channel = Body`) and [`worker_artifact`]
603/// (`channel = Part`, only when `name == "verdict"`). Enforcement Point 2
604/// (the submit-time complement to `Compiler::compile`'s register-time lint
605/// in `mlua_swarm::blueprint::compiler`, Enforcement Point 1) — called
606/// after the final value string is resolved and BEFORE it is handed to
607/// `submit_worker_result_trusted` / `stage_worker_artifact_trusted`, so a
608/// rejected value never reaches the flow ctx.
609///
610/// No-op (`Ok(())`) in every case that must preserve pre-GH-#50 behavior
611/// byte-for-byte:
612/// - the dispatching agent declared no `VerdictContract` at all (opt-in).
613/// - the agent's declared contract addresses the OTHER channel — a
614///   channel/shape mismatch is the compile-time lint's job (Enforcement
615///   Point 1); this gate only validates value membership for the channel
616///   it was called for.
617/// - `value` IS a member of the contract's declared `values`.
618///
619/// `Err(ApiError::unprocessable(..))` (HTTP 422) otherwise, echoing the
620/// expected token set.
621async fn check_verdict_contract(
622    state: &AppState,
623    task_id: &StepId,
624    channel: VerdictChannel,
625    value: &str,
626) -> Result<(), ApiError> {
627    let Some(contract) = state.engine.verdict_contract_for_task(task_id).await else {
628        return Ok(());
629    };
630    if contract.channel != channel {
631        return Ok(());
632    }
633    if contract.values.iter().any(|v| v == value) {
634        return Ok(());
635    }
636    Err(ApiError::unprocessable(format!(
637        "verdict contract violation: {value:?} is not a member of the declared values {:?}",
638        contract.values
639    )))
640}
641
642/// GH #51 — maps the 2 completion-time verdict-contract `EngineError`
643/// variants (raised by the embedded choke point inside
644/// `Engine::submit_worker_result_trusted` / `Engine::submit_output`) to
645/// their `422` HTTP shape; every other `EngineError` variant falls back
646/// to the pre-existing generic `500` `ApiError::engine` wrapping,
647/// unchanged. Shared by [`worker_submit`] and [`worker_result`] — both
648/// routes surface the SAME embedded engine-side check, so their
649/// HTTP-layer error translation is identical too (this is HTTP
650/// status-code translation, not the verdict-contract logic itself, which
651/// stays the single engine-side choke point per GH #51's "not duplicated
652/// into each route handler" constraint).
653///
654/// `context` labels the wrapped `EngineError`'s `Display` text for the
655/// fallback `500` case only, matching the pre-existing
656/// `format!("<call>: {e}")` style each call site used before this
657/// helper.
658fn map_completion_result<T>(result: Result<T, EngineError>, context: &str) -> Result<T, ApiError> {
659    result.map_err(|e| match e {
660        EngineError::VerdictValueRejected { value, allowed } => ApiError::unprocessable(format!(
661            "verdict contract violation: {value:?} is not a member of the declared values {allowed:?}"
662        )),
663        EngineError::VerdictPartMissing { allowed } => ApiError::unprocessable(format!(
664            "verdict contract violation: no staged \"verdict\" part found for this attempt; declared values {allowed:?}"
665        )),
666        other => ApiError::engine(format!("{context}: {other}")),
667    })
668}
669
670/// `POST /v1/worker/submit`. Bearer = encoded `CapToken`. Body = raw text/octet.
671///
672/// Simplification-axis endpoint for SubAgents. Removes the JSON construction,
673/// duplicated `task_id`, and JSON-escape burden of `/v1/worker/result` — the
674/// worker completes a POST with just token + raw body. Origin: the recent clean-up
675/// of the SubAgent contract drift (fewer IDs to pass around, multi-line escape
676/// accidents eliminated).
677///
678/// **GH #42 `@file:` sentinel**: workers whose result body is too large to
679/// re-emit inline (multi-KB structured output) may `Write` the payload to
680/// a file under their task's `work_dir` and submit the body
681/// `@file:<abs-path>` instead — see [`resolve_file_sentinel`].
682/// Non-sentinel bodies pass through unchanged. The step must opt in via
683/// `allow_file_submit: true` (GH #43, default-deny — see
684/// [`FILE_SENTINEL_ALLOW_KEY`]).
685///
686/// **`submit_format: "json"` opt-in**: a step whose meta channel declares
687/// [`SUBMIT_FORMAT_KEY`] as `"json"` gets its final body parsed into a
688/// structured [`Value`] before it is folded into the flow ctx, so a
689/// downstream `fanout` / `branch` can address fields inside it. Applied
690/// after sentinel resolution (so the two combine), declared-strict
691/// (unparseable → `422`), default-deny for every undeclared step — see
692/// [`resolve_submit_value`].
693///
694/// Behavior:
695/// - `task_id` is auto-looked-up server-side from the token (already bound to the `CapToken`).
696/// - Body raw bytes go as-is into `Value::String` for `submit_output` + `post_result`
697///   (unless the step declared `submit_format: "json"`, above).
698/// - `ok=true` fixed (= the submit endpoint is success-path only). For the error
699///   path, use `/v1/worker/result` with an explicit `ok=false`.
700#[derive(Debug, Deserialize, Default)]
701pub struct SubmitQuery {
702    /// Optional. `ok=false` signals failure (= `DispatchOutcome::Blocked`, caught
703    /// by the flow.ir Try path). Unspecified (`None`) is treated as `ok=true`
704    /// (= normal success).
705    #[serde(default)]
706    pub ok: Option<bool>,
707    /// GH #76 HTTP wire: opt-in verdict tier selector. When absent, `ok` alone
708    /// drives the tier (pre-#76 byte-for-byte behavior:
709    /// `ok=true|absent → Pass`, `ok=false → Blocked`). When present, must
710    /// be one of `"pass"`, `"blocked"`, `"skip"` — anything else returns
711    /// 400. `verdict=skip` requires `ok` to be absent or `true`: an
712    /// explicit `verdict=skip&ok=false` is a conflicting signal and also
713    /// returns 400. `verdict=pass` / `verdict=blocked` semantics match
714    /// the corresponding `ok` boolean; a `verdict=pass&ok=false` or
715    /// `verdict=blocked&ok=true` combination is also a conflict → 400.
716    #[serde(default)]
717    pub verdict: Option<String>,
718}
719
720/// GH #76 HTTP wire: resolve the `(ok, verdict)` query-param pair into the
721/// [`SubmitOutcome`] the engine call takes. Kept as a plain free function
722/// (not a method on `SubmitQuery`) so the exhaustive match is unit-testable
723/// from `#[cfg(test)]` without threading an `AppState` through.
724///
725/// - `verdict` absent: `ok=true|None → Pass`, `ok=false → Blocked` (=
726///   pre-#76 wire, byte-for-byte).
727/// - `verdict=pass`: allowed with `ok=true|None`; conflicts with `ok=false`.
728/// - `verdict=blocked`: allowed with `ok=false|None`; conflicts with `ok=true`.
729/// - `verdict=skip`: allowed with `ok=true|None`; conflicts with `ok=false`.
730/// - `verdict` = anything else: `Err` with a message naming the valid set.
731fn resolve_submit_outcome(
732    verdict: Option<&str>,
733    ok: Option<bool>,
734) -> Result<SubmitOutcome, String> {
735    match verdict {
736        None => Ok(if ok.unwrap_or(true) {
737            SubmitOutcome::Pass
738        } else {
739            SubmitOutcome::Blocked
740        }),
741        Some(v) => match v {
742            "pass" => {
743                if ok == Some(false) {
744                    Err(
745                        "conflicting signal: verdict=pass with ok=false; drop one of them"
746                            .to_string(),
747                    )
748                } else {
749                    Ok(SubmitOutcome::Pass)
750                }
751            }
752            "blocked" => {
753                if ok == Some(true) {
754                    Err(
755                        "conflicting signal: verdict=blocked with ok=true; drop one of them"
756                            .to_string(),
757                    )
758                } else {
759                    Ok(SubmitOutcome::Blocked)
760                }
761            }
762            "skip" => {
763                if ok == Some(false) {
764                    Err(
765                        "conflicting signal: verdict=skip with ok=false; drop one of them"
766                            .to_string(),
767                    )
768                } else {
769                    Ok(SubmitOutcome::Skip)
770                }
771            }
772            other => Err(format!(
773                "verdict must be one of: pass, blocked, skip (got {other:?})"
774            )),
775        },
776    }
777}
778
779/// `POST /v1/worker/submit`. Simplified counterpart of [`worker_result`]:
780/// the caller sends only the raw result body, `task_id` is resolved
781/// server-side from the Bearer handle/token, and `ok` defaults to `true`
782/// unless overridden via [`SubmitQuery::ok`]. See the module doc for the
783/// short-handle vs full-`CapToken` Bearer forms.
784pub async fn worker_submit(
785    State(state): State<AppState>,
786    headers: HeaderMap,
787    Query(q): Query<SubmitQuery>,
788    body: axum::body::Bytes,
789) -> Result<StatusCode, ApiError> {
790    // Bearer accepts either (a) `wh-<8 hex>` short handle (recommended for
791    // SubAgents) or (b) base64-wrapped CapToken JSON (the full-token form).
792    let bearer = extract_bearer_raw(&headers)?;
793    let task_id = if let Some(handle) = parse_worker_handle(&bearer) {
794        state
795            .engine
796            .task_id_from_handle(handle)
797            .await
798            .map_err(map_handle_lookup_err)?
799    } else {
800        let token = CapToken::decode(bearer.trim())
801            .map_err(|e| ApiError::bad_request(format!("invalid token: {e}")))?;
802        state
803            .engine
804            .task_id_from_token(&token)
805            .await
806            .map_err(|e| ApiError::engine(format!("task_id_from_token: {e}")))?
807    };
808    let attempt = state
809        .engine
810        .task_attempt(&task_id)
811        .await
812        .map_err(|e| ApiError::engine(format!("task_attempt: {e}")))?;
813    // GH #37: fail loud (410) instead of silently accepting a submit whose
814    // addressed Run is already terminal — see `reject_if_run_terminal`.
815    reject_if_run_terminal(&state, &task_id, attempt).await?;
816    // Strip trailing whitespace (newlines, etc.) so flow.ir `Eq` string matches
817    // don't drift on `"BLOCKED\n" == "BLOCKED"` false results. Origin: the recent clean-up
818    // verdict_loop smoke — sharp-edge removal. Internal `\n` inside the raw bytes
819    // is preserved (= only trailing).
820    let body_str = String::from_utf8_lossy(&body).trim_end().to_string();
821    // GH #42: `@file:<abs-path>` sentinel — pass through unchanged when
822    // absent (byte-for-byte compat with pre-#42 callers).
823    let body_str = resolve_file_sentinel(&state, &task_id, attempt, body_str).await?;
824    // GH #51: the `channel: "body"` submit-time check formerly performed
825    // here (`check_verdict_contract(&state, &task_id, VerdictChannel::Body,
826    // ..)`) is now performed inside `Engine::submit_worker_result_trusted`
827    // itself — the single completion-time choke point shared by all 3
828    // completion routes (see `map_completion_result`'s doc). No separate
829    // call is needed here; `check_verdict_contract` remains in use by
830    // `worker_artifact`'s staging-time `name == "verdict"` early
831    // validation, unchanged.
832    //
833    // `submit_format` handling: `"json"` (strict) parses here or 422s;
834    // `"text"` and undeclared stage the raw string byte-for-byte (see
835    // `resolve_submit_value`) — for undeclared steps the engine's
836    // Final-pull fold later applies its default lenient container parse
837    // (`FoldParse::Lenient`). The verdict-contract check still runs
838    // downstream (inside the engine) on whatever value this produces, so
839    // an undeclared gate agent's bare token is compared exactly as before
840    // (a bare token is scalar, never touched by the lenient fold).
841    let value = resolve_submit_value(&state, &task_id, attempt, body_str).await?;
842
843    // GH #76 HTTP wire: resolve the `(ok, verdict)` query-param pair into the
844    // `SubmitOutcome` the engine call takes. The handle path = trusted
845    // internal API (= the server-minted handle is validated by the earlier
846    // lookup); the full-token path = existing verify-by-token API. Both are
847    // reflected identically into final + last_result. Absent `verdict`
848    // preserves the pre-#76 wire byte-for-byte (`ok=true|absent → Pass`,
849    // `ok=false → Blocked`); `verdict=skip` is the new opt-in third tier
850    // (see [`resolve_submit_outcome`] for the full truth table).
851    let outcome =
852        resolve_submit_outcome(q.verdict.as_deref(), q.ok).map_err(ApiError::bad_request)?;
853    let submit_result = state
854        .engine
855        .submit_worker_result_trusted(&task_id, attempt, value, outcome)
856        .await;
857    map_completion_result(submit_result, "submit_worker_result_trusted")?;
858    Ok(StatusCode::NO_CONTENT)
859}
860
861/// Query params for `POST /v1/worker/artifact`.
862#[derive(Debug, Deserialize)]
863pub struct ArtifactQuery {
864    /// Artifact name (GH #36 ST1: named multi-part worker output). Required
865    /// and non-empty (400 otherwise) — becomes the object key
866    /// `Engine::dispatch_attempt_with`'s Final-pull folds this part under
867    /// (`{"out": <final>, "parts": {<name>: <value>, ...}}`, see that
868    /// method's doc). No character restriction is enforced here (a BP
869    /// author references it via bracket notation, e.g. `$.out.parts["a.b"]`).
870    pub name: String,
871}
872
873/// `POST /v1/worker/artifact?name=<name>`. Bearer = same short-handle /
874/// full-`CapToken` forms as [`worker_submit`]. Body = raw text/octet.
875///
876/// Simplification-axis sibling of [`worker_submit`] (GH #36 ST1): lets a
877/// worker with more than one named result POST each part independently —
878/// same 1-part-per-POST simplicity as `/v1/worker/submit`, no Single Big
879/// JSON the worker has to construct/escape itself — then complete the
880/// attempt with an ordinary `/v1/worker/submit` (unchanged). Staging alone
881/// never completes the attempt; `dispatch_attempt_with` only pulls the
882/// tail's `Final` (whichever endpoint submits it) and folds every staged
883/// `Artifact` into `"parts"` at that point.
884///
885/// Behavior:
886/// - `task_id` is auto-looked-up server-side from the token/handle, same as
887///   [`worker_submit`].
888/// - `name` is required and non-empty; missing or blank → 400.
889/// - Body raw bytes go as-is into `Value::String` (same trailing-whitespace
890///   trim as `worker_submit`) and are staged via
891///   [`mlua_swarm::core::engine::Engine::stage_worker_artifact_trusted`] —
892///   which is also what `materialize_part` writes, so the part FILE is
893///   always the submitted bytes verbatim. The engine's Final-pull fold
894///   applies its default lenient container parse to the part's ctx value
895///   (a JSON object / array part becomes addressable, e.g.
896///   `$.<step>.parts["plan-meta.json"].lanes`); `submit_format: "text"`
897///   on the step opts its parts (and body) out of that parse.
898/// - Staging the same `name` twice within one attempt: last write wins (the
899///   Final-pull fold walks the tail in event order — see its doc).
900pub async fn worker_artifact(
901    State(state): State<AppState>,
902    headers: HeaderMap,
903    Query(q): Query<ArtifactQuery>,
904    body: axum::body::Bytes,
905) -> Result<StatusCode, ApiError> {
906    let name = q.name.trim();
907    if name.is_empty() {
908        return Err(ApiError::bad_request("name must not be empty".into()));
909    }
910    let name = name.to_string();
911
912    let bearer = extract_bearer_raw(&headers)?;
913    let task_id = if let Some(handle) = parse_worker_handle(&bearer) {
914        state
915            .engine
916            .task_id_from_handle(handle)
917            .await
918            .map_err(map_handle_lookup_err)?
919    } else {
920        let token = CapToken::decode(bearer.trim())
921            .map_err(|e| ApiError::bad_request(format!("invalid token: {e}")))?;
922        state
923            .engine
924            .task_id_from_token(&token)
925            .await
926            .map_err(|e| ApiError::engine(format!("task_id_from_token: {e}")))?
927    };
928    let attempt = state
929        .engine
930        .task_attempt(&task_id)
931        .await
932        .map_err(|e| ApiError::engine(format!("task_attempt: {e}")))?;
933    // GH #37: fail loud (410) instead of silently staging a part whose
934    // addressed Run is already terminal — see `reject_if_run_terminal`.
935    reject_if_run_terminal(&state, &task_id, attempt).await?;
936    let body_str = String::from_utf8_lossy(&body).trim_end().to_string();
937    // GH #42: same `@file:<abs-path>` sentinel as `worker_submit`.
938    let body_str = resolve_file_sentinel(&state, &task_id, attempt, body_str).await?;
939    // GH #50: submit-time verdict contract gate (Enforcement Point 2),
940    // ONLY for the literal `"verdict"` part name (Pattern B's staging
941    // channel — see `blueprint-authoring.md`'s "Returning verdicts to
942    // drive BP flow" section). Every other part name skips the gate
943    // entirely, unchanged from pre-GH-#50 behavior.
944    if name == "verdict" {
945        check_verdict_contract(&state, &task_id, VerdictChannel::Part, &body_str).await?;
946    }
947    let value = Value::String(body_str);
948
949    state
950        .engine
951        .stage_worker_artifact_trusted(&task_id, attempt, name, value)
952        .await
953        .map_err(|e| ApiError::engine(format!("stage_worker_artifact_trusted: {e}")))?;
954    Ok(StatusCode::NO_CONTENT)
955}
956
957/// Body for `POST /v1/worker/degradation` (GH #32).
958///
959/// The persisted shape is [`DegradationEntry`], not this struct: the
960/// server injects `step_ref` / `attempt` / `at` on the way in. `JsonSchema`
961/// is derived because this body is hand-authored by worker harness
962/// implementors, who read it from `mse://api/http-endpoints`.
963#[derive(Debug, Deserialize, schemars::JsonSchema)]
964pub struct DegradationBody {
965    /// The tool (or capability) the worker attempted to use.
966    pub tool: String,
967    /// The error that triggered the fallback, in the worker's own words.
968    pub error: String,
969    /// What the worker substituted instead of failing.
970    pub fallback: String,
971    /// Optional free-form context from the worker.
972    #[serde(default)]
973    pub note: Option<String>,
974}
975
976/// `POST /v1/worker/degradation` (GH #32). Bearer = same short-handle /
977/// full-`CapToken` forms as [`worker_submit`]. Body = JSON, not raw bytes —
978/// this endpoint carries structured data, unlike its raw-bytes siblings.
979///
980/// Independent channel: appends a [`DegradationEntry`] to
981/// `RunRecord.degradations` via `RunStore::append_degradation` directly.
982/// Never touches `OutputStore` / the fold path (Crux invariant 2 — a
983/// degradation must not surface as step OUTPUT / `$.step.parts`).
984///
985/// Behavior:
986/// - `task_id` is auto-looked-up server-side from the token/handle, same as
987///   [`worker_submit`] / [`worker_artifact`].
988/// - GH #37 terminal-run guard applies first — a degradation addressed at
989///   an already-terminal Run is rejected with `410 Gone`
990///   ([`reject_if_run_terminal`]), same as a submit/artifact would be.
991/// - `step_ref` / `attempt` / `at` are server-injected — `step_ref` is the
992///   fetching agent's resolved name (`AgentContextView.agent`, the best
993///   proxy for `Step.ref` available at this layer), `attempt` is the
994///   task's current attempt, `at` is now (Unix epoch seconds). The client
995///   body never supplies any of the three.
996/// - No Run linkage in `agent_ctx` (a pre-run-tracking dispatch), an
997///   unparseable `run_id`, or an `append_degradation` call against a Run
998///   the store doesn't actually hold (`RunStoreError::NotFound` — the same
999///   condition [`reject_if_run_terminal`] itself fails open on) all take
1000///   the same silent `204 No Content` path, logged via `tracing::warn!` —
1001///   this is a legitimate no-tracking codepath, not a client error. Any
1002///   other `RunStore` failure propagates as `ApiError::engine`.
1003pub async fn worker_degradation(
1004    State(state): State<AppState>,
1005    headers: HeaderMap,
1006    Json(body): Json<DegradationBody>,
1007) -> Result<StatusCode, ApiError> {
1008    let bearer = extract_bearer_raw(&headers)?;
1009    let task_id = if let Some(handle) = parse_worker_handle(&bearer) {
1010        state
1011            .engine
1012            .task_id_from_handle(handle)
1013            .await
1014            .map_err(map_handle_lookup_err)?
1015    } else {
1016        let token = CapToken::decode(bearer.trim())
1017            .map_err(|e| ApiError::bad_request(format!("invalid token: {e}")))?;
1018        state
1019            .engine
1020            .task_id_from_token(&token)
1021            .await
1022            .map_err(|e| ApiError::engine(format!("task_id_from_token: {e}")))?
1023    };
1024    let attempt = state
1025        .engine
1026        .task_attempt(&task_id)
1027        .await
1028        .map_err(|e| ApiError::engine(format!("task_attempt: {e}")))?;
1029    // GH #37: the same terminal-run guard `worker_submit` / `worker_artifact`
1030    // apply — a dead Run must not accumulate signals.
1031    reject_if_run_terminal(&state, &task_id, attempt).await?;
1032
1033    // Same `with_state` resolution pattern as `reject_if_run_terminal`: an
1034    // engine-level failure here is fail-open too (`_ => ...`), matching
1035    // that guard's own "every resolution step is fail-open" contract —
1036    // this lookup isn't a second, stricter gate on top of it.
1037    let tid = task_id.clone();
1038    let (run_id_str, agent) = match state
1039        .engine
1040        .with_state("worker_degradation_run_lookup", move |s| {
1041            s.agent_ctx.get(&(tid, attempt)).and_then(|e| {
1042                e.view
1043                    .run_id
1044                    .clone()
1045                    .map(|run_id| (run_id, e.view.agent.clone()))
1046            })
1047        })
1048        .await
1049    {
1050        Ok(Some(pair)) => pair,
1051        _ => {
1052            tracing::warn!(%task_id, "worker_degradation: no run linkage for this task; entry dropped");
1053            return Ok(StatusCode::NO_CONTENT);
1054        }
1055    };
1056    let Ok(run_id) = RunId::parse(run_id_str) else {
1057        tracing::warn!(%task_id, "worker_degradation: run_id failed to parse; entry dropped");
1058        return Ok(StatusCode::NO_CONTENT);
1059    };
1060
1061    // RunTrace mirror: the degradation also lands on the per-Run trace
1062    // stream (`worker.degradation`) so the timeline is self-contained —
1063    // the authoritative record stays `RunRecord.degradations` below.
1064    mlua_swarm::store::trace::TraceHandle::new(run_id.clone(), state.run_trace_store.clone())
1065        .append(
1066            mlua_swarm::store::trace::kind::WORKER_DEGRADATION,
1067            Some(agent.as_str()),
1068            Some(attempt),
1069            serde_json::json!({
1070                "tool": body.tool.as_str(),
1071                "error": body.error.as_str(),
1072                "fallback": body.fallback.as_str(),
1073            }),
1074        )
1075        .await;
1076
1077    let entry = DegradationEntry {
1078        tool: body.tool,
1079        error: body.error,
1080        fallback: body.fallback,
1081        note: body.note,
1082        step_ref: Some(agent),
1083        attempt: Some(attempt),
1084        at: crate::tasks::now_secs(),
1085    };
1086    match state.run_store.append_degradation(&run_id, entry).await {
1087        Ok(()) => Ok(StatusCode::NO_CONTENT),
1088        Err(RunStoreError::NotFound(_)) => {
1089            tracing::warn!(%task_id, %run_id, "worker_degradation: run not found in run_store; entry dropped");
1090            Ok(StatusCode::NO_CONTENT)
1091        }
1092        Err(e) => Err(ApiError::engine(format!("append_degradation: {e}"))),
1093    }
1094}
1095
1096/// Request body for `POST /v1/worker/stats` — a worker's self-reported
1097/// per-attempt stats (per-step run stats, operator axis). Every field
1098/// optional; an all-empty body is accepted and dropped.
1099///
1100/// Field-for-field the wire twin of [`mlua_swarm::store::trace::WorkerStats`],
1101/// which is what the handler converts it into; the one difference is this
1102/// body's `worker_kind` default of `"operator"`. The property sets are
1103/// drift-locked by `stats_body_schema_matches_worker_stats_property_set`.
1104/// `JsonSchema` is derived because this body is hand-authored by worker
1105/// harness implementors, who read it from `mse://api/http-endpoints`.
1106#[derive(Debug, Deserialize, schemars::JsonSchema)]
1107pub struct StatsBody {
1108    /// Worker kind label. Defaults to `"operator"` — this endpoint's
1109    /// primary caller is the WS-operator / SubAgent axis, whose spawn
1110    /// path has no in-process fold site to attach stats at.
1111    #[serde(default)]
1112    pub worker_kind: Option<String>,
1113    /// The model that served the attempt, self-reported.
1114    #[serde(default)]
1115    pub model: Option<String>,
1116    /// Normalized token usage.
1117    #[serde(default)]
1118    pub usage: Option<mlua_swarm::store::trace::TokenUsage>,
1119    /// Number of LLM turns the attempt ran.
1120    #[serde(default)]
1121    pub num_turns: Option<u32>,
1122    /// Free-form worker-specific detail (size-capped on fold).
1123    #[serde(default)]
1124    pub adapter_data: Option<Value>,
1125}
1126
1127/// `POST /v1/worker/stats`. Bearer = same short-handle / full-`CapToken`
1128/// forms as [`worker_submit`]. Body = JSON ([`StatsBody`]).
1129///
1130/// Records normalized per-attempt worker stats via
1131/// `Engine::record_worker_stats`; the dispatcher's outcome fold drains
1132/// them into the terminal `StepEntry`. Sibling of
1133/// [`worker_degradation`] on the observational plane: never touches the
1134/// fold path / step OUTPUT, and SHOULD be called before the final
1135/// `/v1/worker/submit` (the dispatcher folds at outcome time — stats
1136/// arriving after the fold are dropped with the attempt's cleanup).
1137pub async fn worker_stats(
1138    State(state): State<AppState>,
1139    headers: HeaderMap,
1140    Json(body): Json<StatsBody>,
1141) -> Result<StatusCode, ApiError> {
1142    let bearer = extract_bearer_raw(&headers)?;
1143    let task_id = if let Some(handle) = parse_worker_handle(&bearer) {
1144        state
1145            .engine
1146            .task_id_from_handle(handle)
1147            .await
1148            .map_err(map_handle_lookup_err)?
1149    } else {
1150        let token = CapToken::decode(bearer.trim())
1151            .map_err(|e| ApiError::bad_request(format!("invalid token: {e}")))?;
1152        state
1153            .engine
1154            .task_id_from_token(&token)
1155            .await
1156            .map_err(|e| ApiError::engine(format!("task_id_from_token: {e}")))?
1157    };
1158    let attempt = state
1159        .engine
1160        .task_attempt(&task_id)
1161        .await
1162        .map_err(|e| ApiError::engine(format!("task_attempt: {e}")))?;
1163    // GH #37: the same terminal-run guard as the sibling worker routes —
1164    // a dead Run must not accumulate signals.
1165    reject_if_run_terminal(&state, &task_id, attempt).await?;
1166
1167    let stats = mlua_swarm::store::trace::WorkerStats {
1168        worker_kind: Some(body.worker_kind.unwrap_or_else(|| "operator".to_string())),
1169        model: body.model,
1170        usage: body.usage,
1171        num_turns: body.num_turns,
1172        adapter_data: body.adapter_data,
1173    };
1174    state
1175        .engine
1176        .record_worker_stats(&task_id, attempt, stats)
1177        .await;
1178    Ok(StatusCode::NO_CONTENT)
1179}
1180
1181/// GH #37: terminal-run guard shared by [`worker_submit`] / [`worker_artifact`].
1182///
1183/// Resolves the dispatch task's `AgentContextView.run_id` (threaded at
1184/// spawn time when a `RunContext` accompanied the launch) and rejects the
1185/// submit with `410 Gone` when the addressed Run has already reached a
1186/// terminal status (`Done` / `Failed` / `Interrupted`) — the flow-eval
1187/// driver for that Run is gone, so the staged/final value could never be
1188/// folded into a flow context. Before this guard, such a submit was
1189/// silently accepted with `204` and the worker's output orphaned — the
1190/// exact failure shape observed when a long-running worker outlived the
1191/// GH #33 sync launch ceiling.
1192///
1193/// Every resolution step is fail-open (missing agent-ctx entry / missing
1194/// `run_id` / unparseable id / unknown Run → `Ok(())`), matching this
1195/// crate's other best-effort projection hooks: a pre-run-tracking dispatch
1196/// must keep working exactly as before.
1197async fn reject_if_run_terminal(
1198    state: &AppState,
1199    task_id: &StepId,
1200    attempt: u32,
1201) -> Result<(), ApiError> {
1202    let tid = task_id.clone();
1203    let run_id_str = match state
1204        .engine
1205        .with_state("worker_terminal_run_guard", move |s| {
1206            s.agent_ctx
1207                .get(&(tid, attempt))
1208                .and_then(|e| e.view.run_id.clone())
1209        })
1210        .await
1211    {
1212        Ok(Some(rid)) => rid,
1213        _ => return Ok(()),
1214    };
1215    let Ok(run_id) = RunId::parse(run_id_str) else {
1216        return Ok(());
1217    };
1218    let Ok(rec) = state.run_store.get(&run_id).await else {
1219        return Ok(());
1220    };
1221    match rec.status {
1222        RunStatus::Done | RunStatus::Failed | RunStatus::Interrupted | RunStatus::Cancelled => {
1223            Err(ApiError::gone(format!(
1224                "run {run_id} is already terminal ({:?}): this attempt's output cannot be \
1225             delivered to a flow context; re-kick the task (POST /v1/tasks/:id/runs) and \
1226             fetch a fresh prompt",
1227                rec.status
1228            )))
1229        }
1230        RunStatus::Pending | RunStatus::Running => Ok(()),
1231    }
1232}
1233
1234/// Query params for `GET /v1/worker/prompt/system`. Field names are fixed to
1235/// `task_id` / `attempt` — this is the exact shape the engine bakes into
1236/// `system_ref.uri`'s query string for `Http` mode (GH #31), so the names
1237/// here must match verbatim.
1238#[derive(Debug, Deserialize)]
1239pub struct PromptSystemQuery {
1240    /// Task the fetched raw system prompt belongs to; cross-checked
1241    /// against the Bearer handle/token, same as [`PromptQuery::task_id`].
1242    pub task_id: StepId,
1243    /// Attempt number the baked system prompt was recorded under.
1244    pub attempt: u32,
1245}
1246
1247/// `GET /v1/worker/prompt/system?task_id=<tid>&attempt=<n>` (GH #31). The
1248/// `Http`-mode fetch target for `system_ref.uri`: serves the exact baked
1249/// `system` bytes for `(task_id, attempt)` as a raw `text/plain` body — not
1250/// JSON-wrapped, since `mse_worker_fetch` needs the precise byte sequence to
1251/// sha256-verify against `system_ref.sha256`.
1252///
1253/// Same Bearer auth flow as [`worker_prompt`] (short handle or full
1254/// `CapToken`); 404 via [`ApiError::not_found`] if no baked system exists for
1255/// that `(task_id, attempt)`.
1256pub async fn worker_prompt_system(
1257    State(state): State<AppState>,
1258    headers: HeaderMap,
1259    Query(q): Query<PromptSystemQuery>,
1260) -> Result<impl axum::response::IntoResponse, ApiError> {
1261    let task_id = q.task_id;
1262    let attempt = q.attempt;
1263    let bearer = extract_bearer_raw(&headers)?;
1264    if let Some(handle) = parse_worker_handle(&bearer) {
1265        let resolved = state
1266            .engine
1267            .task_id_from_handle(handle)
1268            .await
1269            .map_err(map_handle_lookup_err)?;
1270        if resolved != task_id {
1271            return Err(ApiError::bad_request(format!(
1272                "handle {handle} is bound to task {resolved}, not {task_id}"
1273            )));
1274        }
1275    } else {
1276        let token = CapToken::decode(bearer.trim())
1277            .map_err(|e| ApiError::bad_request(format!("invalid token: {e}")))?;
1278        state
1279            .engine
1280            .verify_token_for_task(&token, mlua_swarm::Verb::FetchPrompt, &task_id)
1281            .await
1282            .map_err(|e| ApiError::engine(format!("verify_token_for_task: {e}")))?;
1283    }
1284    let system = state
1285        .engine
1286        .raw_system_prompt(&task_id, attempt)
1287        .await
1288        .map_err(|e| ApiError::engine(format!("raw_system_prompt: {e}")))?
1289        .ok_or_else(|| {
1290            ApiError::not_found(format!(
1291                "no baked system prompt for task {task_id} attempt {attempt}"
1292            ))
1293        })?;
1294    Ok((
1295        [(header::CONTENT_TYPE, "text/plain; charset=utf-8")],
1296        system,
1297    ))
1298}
1299
1300/// Response body for `GET /v1/agents/:name/render-size`.
1301#[derive(Debug, serde::Serialize)]
1302pub struct AgentRenderSizeResponse {
1303    /// The agent name looked up (echoed back verbatim from the path param).
1304    pub agent: String,
1305    /// Most-recently-baked `system_prompt` render size in bytes for this
1306    /// agent, or `None` if `bake_worker_system_prompt` has never recorded
1307    /// one (a freshly-added agent that has never been dispatched).
1308    pub last_rendered_bytes: Option<usize>,
1309}
1310
1311/// `GET /v1/agents/:name/render-size` (GH #31). Live per-agent-name lookup
1312/// of the most-recently-baked `system_prompt` render size, backing
1313/// `bp_doctor`'s post-render size check. No Bearer required — same
1314/// unauthenticated trust tier as `GET /v1/blueprints/:id/head`
1315/// (`blueprints::get_head`), an operator-diagnostic route.
1316///
1317/// `last_rendered_bytes: null` is a normal, expected response (a
1318/// freshly-added agent that has never been dispatched yet) — always
1319/// `200 OK`, never a 404.
1320pub async fn agent_render_size(
1321    State(state): State<AppState>,
1322    axum::extract::Path(name): axum::extract::Path<String>,
1323) -> Json<AgentRenderSizeResponse> {
1324    let last_rendered_bytes = state.engine.agent_last_rendered_size(&name).await;
1325    Json(AgentRenderSizeResponse {
1326        agent: name,
1327        last_rendered_bytes,
1328    })
1329}
1330
1331/// Extracts the raw string from the `Authorization` header (= strips the `Bearer `
1332/// prefix). To let `worker_submit` accept both short handles and full tokens, we
1333/// fetch the raw value before any decode.
1334fn extract_bearer_raw(headers: &HeaderMap) -> Result<String, ApiError> {
1335    let v = headers
1336        .get(AUTHORIZATION)
1337        .ok_or_else(|| ApiError::bad_request("missing Authorization header".into()))?
1338        .to_str()
1339        .map_err(|_| ApiError::bad_request("invalid Authorization header encoding".into()))?;
1340    let s = v
1341        .strip_prefix("Bearer ")
1342        .ok_or_else(|| ApiError::bad_request("Authorization must be 'Bearer <token>'".into()))?
1343        .trim();
1344    if s.is_empty() {
1345        return Err(ApiError::bad_request("Bearer is empty".into()));
1346    }
1347    Ok(s.to_string())
1348}
1349
1350/// Maps a `task_id_from_handle` failure on the short-handle Bearer path. A
1351/// `wh-`-prefixed handle that parsed as well-formed (via
1352/// [`parse_worker_handle`]) but is unknown to the engine
1353/// (`EngineError::TokenNotFound`) is a handle that was minted before the
1354/// engine's in-memory state was wiped — typically a server restart. "Once
1355/// valid, now gone" is exactly `410 Gone`: the worker should re-kick its
1356/// task and fetch a fresh handle rather than treat this as a server fault.
1357/// Every other engine error stays a `500` (unchanged), same wrapping style
1358/// as the pre-existing call sites.
1359///
1360/// Only reached on the handle path (`parse_worker_handle` returned `Some`),
1361/// so it never re-labels a full-`CapToken` decode/verify failure.
1362fn map_handle_lookup_err(e: EngineError) -> ApiError {
1363    match e {
1364        EngineError::TokenNotFound(_) => ApiError::gone(
1365            "worker handle is no longer valid (the engine's in-flight state was reset, \
1366             e.g. by a server restart): re-kick the task (POST /v1/tasks/:id/runs) and \
1367             fetch a fresh prompt/handle"
1368                .to_string(),
1369        ),
1370        other => ApiError::engine(format!("task_id_from_handle: {other}")),
1371    }
1372}
1373
1374/// Decides whether the Bearer is a short handle (`wh-XXXXXXXX`). Returns
1375/// `Some(handle)` on a match, `None` otherwise (= caller proceeds to try decoding
1376/// as full `CapToken` JSON).
1377fn parse_worker_handle(s: &str) -> Option<&str> {
1378    let s = s.trim();
1379    if s.starts_with("wh-")
1380        && s.len() >= 5
1381        && s.len() <= 64
1382        && s[3..].chars().all(|c| c.is_ascii_alphanumeric())
1383    {
1384        Some(s)
1385    } else {
1386        None
1387    }
1388}
1389
1390/// Decodes an encoded `CapToken` from `Authorization: Bearer <encoded CapToken>`.
1391/// Kept separate from `extract_bearer` (sid-only) — kept as a distinct fn so
1392/// that sid strings and encoded tokens are not confused, distinguishing them by type.
1393fn decode_worker_bearer(headers: &HeaderMap) -> Result<CapToken, ApiError> {
1394    let v = headers
1395        .get(AUTHORIZATION)
1396        .ok_or_else(|| ApiError::bad_request("missing Authorization header".into()))?
1397        .to_str()
1398        .map_err(|_| ApiError::bad_request("invalid Authorization header encoding".into()))?;
1399    let encoded = v
1400        .strip_prefix("Bearer ")
1401        .ok_or_else(|| ApiError::bad_request("Authorization must be 'Bearer <token>'".into()))?
1402        .trim();
1403    if encoded.is_empty() {
1404        return Err(ApiError::bad_request("Bearer token is empty".into()));
1405    }
1406    CapToken::decode(encoded).map_err(|e| ApiError::bad_request(format!("invalid token: {e}")))
1407}
1408
1409// ──────────────────────────────────────────────────────────────────────────
1410// UT — `assemble_step_pointers` (`projection-adapter` ST5 Worker axis)
1411// ──────────────────────────────────────────────────────────────────────────
1412
1413#[cfg(test)]
1414mod tests {
1415    use super::*;
1416    use axum::response::IntoResponse;
1417    use mlua_swarm::core::agent_context::AgentContextView;
1418    use mlua_swarm::core::config::EngineCfg;
1419    use mlua_swarm::core::engine::Engine;
1420    use mlua_swarm::store::output::{InMemoryOutputStore, OutputStore};
1421    use mlua_swarm::store::run::{InMemoryRunStore, RunRecord, RunStatus, RunStore, StepEntry};
1422    use mlua_swarm::store::task::InMemoryTaskStore;
1423    use mlua_swarm::{RunId, StepId, TaskId};
1424    use serde_json::json;
1425    use std::collections::HashMap;
1426    use std::sync::Arc;
1427    use tokio::sync::Mutex;
1428
1429    /// Per-module test-helper convention (this crate's established
1430    /// pattern — see e.g. `projection::tests::test_state`): a minimal
1431    /// `AppState` wired with the caller-supplied `data_store` / `run_store`
1432    /// so a test can seed both directly rather than driving a real
1433    /// dispatch through them.
1434    fn test_state(data_store: Arc<dyn OutputStore>, run_store: Arc<dyn RunStore>) -> AppState {
1435        let engine = Engine::new(EngineCfg::default());
1436        let compiler = mlua_swarm::Compiler::new(crate::default_registry());
1437        let launch = Arc::new(mlua_swarm::TaskLaunchService::new(engine.clone(), compiler));
1438        AppState {
1439            engine,
1440            sessions: Arc::new(Mutex::new(crate::SessionStore::default())),
1441            task_app: Arc::new(mlua_swarm::TaskApplication::new_inline_only(launch)),
1442            ws_operator_factory: None,
1443            data_store,
1444            operator_sessions: Arc::new(Mutex::new(HashMap::new())),
1445            roles_to_sid: Arc::new(Mutex::new(HashMap::new())),
1446            task_store: Arc::new(InMemoryTaskStore::new()),
1447            run_store,
1448            replay_store: Arc::new(mlua_swarm::store::replay::InMemoryReplayStore::new()),
1449            run_trace_store: Arc::new(mlua_swarm::store::trace::InMemoryRunTraceStore::new()),
1450            base_url: None,
1451            sync_timeout_secs: 300,
1452        }
1453    }
1454
1455    // GH #76 HTTP wire — `resolve_submit_outcome` truth table. Guards the
1456    // exhaustive `(verdict, ok)` mapping against silent regressions when
1457    // future work adds new tiers under `#[non_exhaustive]` `SubmitOutcome`.
1458    #[test]
1459    fn resolve_submit_outcome_absent_verdict_preserves_pre_gh76_wire() {
1460        // ok=None / ok=true / ok=false without verdict → Pass / Pass / Blocked.
1461        assert!(matches!(
1462            resolve_submit_outcome(None, None),
1463            Ok(SubmitOutcome::Pass)
1464        ));
1465        assert!(matches!(
1466            resolve_submit_outcome(None, Some(true)),
1467            Ok(SubmitOutcome::Pass)
1468        ));
1469        assert!(matches!(
1470            resolve_submit_outcome(None, Some(false)),
1471            Ok(SubmitOutcome::Blocked)
1472        ));
1473    }
1474
1475    #[test]
1476    fn resolve_submit_outcome_verdict_pass_and_blocked_match_ok_bool_or_default() {
1477        assert!(matches!(
1478            resolve_submit_outcome(Some("pass"), None),
1479            Ok(SubmitOutcome::Pass)
1480        ));
1481        assert!(matches!(
1482            resolve_submit_outcome(Some("pass"), Some(true)),
1483            Ok(SubmitOutcome::Pass)
1484        ));
1485        assert!(resolve_submit_outcome(Some("pass"), Some(false)).is_err());
1486
1487        assert!(matches!(
1488            resolve_submit_outcome(Some("blocked"), None),
1489            Ok(SubmitOutcome::Blocked)
1490        ));
1491        assert!(matches!(
1492            resolve_submit_outcome(Some("blocked"), Some(false)),
1493            Ok(SubmitOutcome::Blocked)
1494        ));
1495        assert!(resolve_submit_outcome(Some("blocked"), Some(true)).is_err());
1496    }
1497
1498    #[test]
1499    fn resolve_submit_outcome_verdict_skip_is_ok_true_only() {
1500        assert!(matches!(
1501            resolve_submit_outcome(Some("skip"), None),
1502            Ok(SubmitOutcome::Skip)
1503        ));
1504        assert!(matches!(
1505            resolve_submit_outcome(Some("skip"), Some(true)),
1506            Ok(SubmitOutcome::Skip)
1507        ));
1508        // The conflict case the HTTP wire HTTP handler surfaces as 400.
1509        let err = resolve_submit_outcome(Some("skip"), Some(false))
1510            .expect_err("skip + ok=false must be a conflict");
1511        assert!(
1512            err.contains("conflict") || err.contains("conflicting"),
1513            "err should name the conflict: {err}"
1514        );
1515    }
1516
1517    #[test]
1518    fn resolve_submit_outcome_invalid_verdict_names_valid_set() {
1519        let err = resolve_submit_outcome(Some("bogus"), None)
1520            .expect_err("unknown verdict must be an error");
1521        assert!(
1522            err.contains("pass") && err.contains("blocked") && err.contains("skip"),
1523            "err should enumerate the valid tier set: {err}"
1524        );
1525    }
1526
1527    /// Drift lock: [`StatsBody`] is the wire twin of
1528    /// [`mlua_swarm::store::trace::WorkerStats`] — the handler builds one
1529    /// from the other field by field. A field added to either side alone
1530    /// silently drops that field from `POST /v1/worker/stats` (or publishes
1531    /// one the endpoint cannot accept), so the two property sets are
1532    /// asserted equal rather than left to review.
1533    #[test]
1534    fn stats_body_schema_matches_worker_stats_property_set() {
1535        fn property_names<T: schemars::JsonSchema>() -> std::collections::BTreeSet<String> {
1536            let schema = serde_json::to_value(schemars::schema_for!(T))
1537                .expect("schema must serialize as JSON");
1538            schema
1539                .get("properties")
1540                .and_then(|p| p.as_object())
1541                .expect("schema must expose a properties object")
1542                .keys()
1543                .cloned()
1544                .collect()
1545        }
1546
1547        assert_eq!(
1548            property_names::<StatsBody>(),
1549            property_names::<mlua_swarm::store::trace::WorkerStats>(),
1550            "StatsBody and WorkerStats must expose the same property set"
1551        );
1552    }
1553
1554    async fn append_final(
1555        data_store: &Arc<dyn OutputStore>,
1556        task_id: &str,
1557        producer: &str,
1558        value: Value,
1559    ) {
1560        data_store
1561            .append(
1562                task_id,
1563                1,
1564                producer,
1565                OutputEvent::Final {
1566                    content: ContentRef::Inline { value },
1567                    ok: true,
1568                },
1569                vec![],
1570            )
1571            .await
1572            .expect("append final");
1573    }
1574
1575    fn step_entry(step_id: &StepId, step_ref: &str) -> StepEntry {
1576        StepEntry::basic(
1577            step_id.clone(),
1578            Some(step_ref.to_string()),
1579            Some("passed".to_string()),
1580            None,
1581            0,
1582        )
1583    }
1584
1585    fn run_record(task_id: &TaskId, run_id: &RunId, step_entries: Vec<StepEntry>) -> RunRecord {
1586        RunRecord {
1587            id: run_id.clone(),
1588            task_id: task_id.clone(),
1589            status: RunStatus::Running,
1590            step_entries,
1591            degradations: Vec::new(),
1592            operator_sid: None,
1593            result_ref: None,
1594            input_json: None,
1595            created_at: 0,
1596            updated_at: 0,
1597        }
1598    }
1599
1600    fn consumer_payload(consumer_step_id: &StepId, run_id: &RunId) -> WorkerPayload {
1601        WorkerPayload {
1602            task_id: consumer_step_id.clone(),
1603            attempt: 1,
1604            agent: "consumer".to_string(),
1605            system: None,
1606            prompt: String::new(),
1607            context: Some(AgentContextView {
1608                task_id: consumer_step_id.to_string(),
1609                agent: "consumer".to_string(),
1610                attempt: 1,
1611                run_id: Some(run_id.to_string()),
1612                ..Default::default()
1613            }),
1614            system_ref: None,
1615        }
1616    }
1617
1618    /// Test 1: `ContextPolicy.steps` unspecified (no policy seeded at all
1619    /// — `Engine::context_policy_for`'s "no entry" default is `None` /
1620    /// pass-all) → the fetch payload carries every submitted step's
1621    /// `StepPointer`.
1622    #[tokio::test]
1623    async fn context_policy_unspecified_yields_every_submitted_step() {
1624        let data_store: Arc<dyn OutputStore> = Arc::new(InMemoryOutputStore::new());
1625        let run_store: Arc<dyn RunStore> = Arc::new(InMemoryRunStore::new());
1626        let task_id = TaskId::new();
1627        let run_id = RunId::new();
1628        let planner_id = StepId::new();
1629        let coder_id = StepId::new();
1630
1631        append_final(
1632            &data_store,
1633            planner_id.as_str(),
1634            "planner",
1635            json!({"plan": "x"}),
1636        )
1637        .await;
1638        append_final(
1639            &data_store,
1640            coder_id.as_str(),
1641            "coder",
1642            json!({"code": "y"}),
1643        )
1644        .await;
1645        run_store
1646            .create(run_record(
1647                &task_id,
1648                &run_id,
1649                vec![
1650                    step_entry(&planner_id, "planner"),
1651                    step_entry(&coder_id, "coder"),
1652                ],
1653            ))
1654            .await
1655            .expect("create run");
1656
1657        let state = test_state(data_store, run_store);
1658        let consumer_id = StepId::new();
1659        let mut payload = consumer_payload(&consumer_id, &run_id);
1660        assemble_step_pointers(&state, &mut payload).await;
1661
1662        let names: Vec<&str> = payload
1663            .context
1664            .as_ref()
1665            .expect("context")
1666            .steps
1667            .iter()
1668            .map(|p| p.name.as_str())
1669            .collect();
1670        assert!(names.contains(&"planner"), "names: {names:?}");
1671        assert!(names.contains(&"coder"), "names: {names:?}");
1672    }
1673
1674    /// Test 2: `steps: ["planner"]` → only `planner`'s pointer is present.
1675    #[tokio::test]
1676    async fn context_policy_steps_include_list_filters_to_named_steps() {
1677        let data_store: Arc<dyn OutputStore> = Arc::new(InMemoryOutputStore::new());
1678        let run_store: Arc<dyn RunStore> = Arc::new(InMemoryRunStore::new());
1679        let task_id = TaskId::new();
1680        let run_id = RunId::new();
1681        let planner_id = StepId::new();
1682        let coder_id = StepId::new();
1683        append_final(&data_store, planner_id.as_str(), "planner", json!("x")).await;
1684        append_final(&data_store, coder_id.as_str(), "coder", json!("y")).await;
1685        run_store
1686            .create(run_record(
1687                &task_id,
1688                &run_id,
1689                vec![
1690                    step_entry(&planner_id, "planner"),
1691                    step_entry(&coder_id, "coder"),
1692                ],
1693            ))
1694            .await
1695            .expect("create run");
1696
1697        let state = test_state(data_store, run_store);
1698        let consumer_id = StepId::new();
1699        state
1700            .engine
1701            .with_state("test.seed_policy", {
1702                let consumer_id = consumer_id.clone();
1703                move |s| {
1704                    s.agent_ctx.insert(
1705                        (consumer_id, 1),
1706                        mlua_swarm::core::state::AgentCtxEntry {
1707                            policy: mlua_swarm_schema::ContextPolicy {
1708                                steps: Some(vec!["planner".to_string()]),
1709                                ..Default::default()
1710                            },
1711                            ..Default::default()
1712                        },
1713                    );
1714                }
1715            })
1716            .await
1717            .expect("seed policy");
1718
1719        let mut payload = consumer_payload(&consumer_id, &run_id);
1720        assemble_step_pointers(&state, &mut payload).await;
1721
1722        let names: Vec<&str> = payload
1723            .context
1724            .as_ref()
1725            .expect("context")
1726            .steps
1727            .iter()
1728            .map(|p| p.name.as_str())
1729            .collect();
1730        assert_eq!(names, vec!["planner"], "names: {names:?}");
1731    }
1732
1733    /// Test 3: `steps: []` → the pointer list is empty.
1734    #[tokio::test]
1735    async fn context_policy_steps_empty_list_yields_no_pointers() {
1736        let data_store: Arc<dyn OutputStore> = Arc::new(InMemoryOutputStore::new());
1737        let run_store: Arc<dyn RunStore> = Arc::new(InMemoryRunStore::new());
1738        let task_id = TaskId::new();
1739        let run_id = RunId::new();
1740        let planner_id = StepId::new();
1741        append_final(&data_store, planner_id.as_str(), "planner", json!("x")).await;
1742        run_store
1743            .create(run_record(
1744                &task_id,
1745                &run_id,
1746                vec![step_entry(&planner_id, "planner")],
1747            ))
1748            .await
1749            .expect("create run");
1750
1751        let state = test_state(data_store, run_store);
1752        let consumer_id = StepId::new();
1753        state
1754            .engine
1755            .with_state("test.seed_policy", {
1756                let consumer_id = consumer_id.clone();
1757                move |s| {
1758                    s.agent_ctx.insert(
1759                        (consumer_id, 1),
1760                        mlua_swarm::core::state::AgentCtxEntry {
1761                            policy: mlua_swarm_schema::ContextPolicy {
1762                                steps: Some(vec![]),
1763                                ..Default::default()
1764                            },
1765                            ..Default::default()
1766                        },
1767                    );
1768                }
1769            })
1770            .await
1771            .expect("seed policy");
1772
1773        let mut payload = consumer_payload(&consumer_id, &run_id);
1774        assemble_step_pointers(&state, &mut payload).await;
1775
1776        assert!(payload.context.expect("context").steps.is_empty());
1777    }
1778
1779    /// Test 4: `steps_exclude` wins over `steps` for a name in both.
1780    #[tokio::test]
1781    async fn context_policy_steps_exclude_wins_over_steps() {
1782        let data_store: Arc<dyn OutputStore> = Arc::new(InMemoryOutputStore::new());
1783        let run_store: Arc<dyn RunStore> = Arc::new(InMemoryRunStore::new());
1784        let task_id = TaskId::new();
1785        let run_id = RunId::new();
1786        let planner_id = StepId::new();
1787        let coder_id = StepId::new();
1788        append_final(&data_store, planner_id.as_str(), "planner", json!("x")).await;
1789        append_final(&data_store, coder_id.as_str(), "coder", json!("y")).await;
1790        run_store
1791            .create(run_record(
1792                &task_id,
1793                &run_id,
1794                vec![
1795                    step_entry(&planner_id, "planner"),
1796                    step_entry(&coder_id, "coder"),
1797                ],
1798            ))
1799            .await
1800            .expect("create run");
1801
1802        let state = test_state(data_store, run_store);
1803        let consumer_id = StepId::new();
1804        state
1805            .engine
1806            .with_state("test.seed_policy", {
1807                let consumer_id = consumer_id.clone();
1808                move |s| {
1809                    s.agent_ctx.insert(
1810                        (consumer_id, 1),
1811                        mlua_swarm::core::state::AgentCtxEntry {
1812                            policy: mlua_swarm_schema::ContextPolicy {
1813                                steps: Some(vec!["planner".to_string(), "coder".to_string()]),
1814                                steps_exclude: vec!["planner".to_string()],
1815                                ..Default::default()
1816                            },
1817                            ..Default::default()
1818                        },
1819                    );
1820                }
1821            })
1822            .await
1823            .expect("seed policy");
1824
1825        let mut payload = consumer_payload(&consumer_id, &run_id);
1826        assemble_step_pointers(&state, &mut payload).await;
1827
1828        let names: Vec<&str> = payload
1829            .context
1830            .as_ref()
1831            .expect("context")
1832            .steps
1833            .iter()
1834            .map(|p| p.name.as_str())
1835            .collect();
1836        assert_eq!(names, vec!["coder"], "names: {names:?}");
1837    }
1838
1839    /// Test 5 (in-flight window, subtask-4-style invariant): the Run has
1840    /// NOT finalized (`result_ref: None`, mirroring a Run still `Running`)
1841    /// yet the fetch payload still carries a `StepPointer` for a step
1842    /// already visible through the Data-plane store — the same mechanism
1843    /// `crates/mlua-swarm-server/src/projection.rs`'s
1844    /// `steps_list_returns_in_flight_step_output_before_run_completes`
1845    /// proves end-to-end through a real gated 2-step dispatch; this test
1846    /// isolates the same invariant at the `assemble_step_pointers` level.
1847    #[tokio::test]
1848    async fn in_flight_step_output_is_visible_before_run_finalizes() {
1849        let data_store: Arc<dyn OutputStore> = Arc::new(InMemoryOutputStore::new());
1850        let run_store: Arc<dyn RunStore> = Arc::new(InMemoryRunStore::new());
1851        let task_id = TaskId::new();
1852        let run_id = RunId::new();
1853        let step1_id = StepId::new();
1854        append_final(
1855            &data_store,
1856            step1_id.as_str(),
1857            "step1",
1858            json!({"step1_out": "hi"}),
1859        )
1860        .await;
1861        let mut run = run_record(&task_id, &run_id, vec![step_entry(&step1_id, "step1")]);
1862        run.status = RunStatus::Running;
1863        run.result_ref = None; // the in-flight window: not yet finalized.
1864        run_store.create(run).await.expect("create run");
1865
1866        let state = test_state(data_store, run_store);
1867        let consumer_id = StepId::new();
1868        let mut payload = consumer_payload(&consumer_id, &run_id);
1869        assemble_step_pointers(&state, &mut payload).await;
1870
1871        let steps = &payload.context.expect("context").steps;
1872        assert_eq!(steps.len(), 1);
1873        assert_eq!(steps[0].name, "step1");
1874    }
1875
1876    /// Test 6: the fetching agent's own name is always excluded, even if
1877    /// (e.g. a loop re-dispatching the same agent) it also appears in
1878    /// `run.step_entries` with a resolvable Data-plane record.
1879    #[tokio::test]
1880    async fn self_agent_name_is_always_excluded() {
1881        let data_store: Arc<dyn OutputStore> = Arc::new(InMemoryOutputStore::new());
1882        let run_store: Arc<dyn RunStore> = Arc::new(InMemoryRunStore::new());
1883        let task_id = TaskId::new();
1884        let run_id = RunId::new();
1885        let planner_id = StepId::new();
1886        let consumer_prior_id = StepId::new();
1887        append_final(&data_store, planner_id.as_str(), "planner", json!("x")).await;
1888        append_final(
1889            &data_store,
1890            consumer_prior_id.as_str(),
1891            "consumer",
1892            json!("self"),
1893        )
1894        .await;
1895        run_store
1896            .create(run_record(
1897                &task_id,
1898                &run_id,
1899                vec![
1900                    step_entry(&planner_id, "planner"),
1901                    step_entry(&consumer_prior_id, "consumer"),
1902                ],
1903            ))
1904            .await
1905            .expect("create run");
1906
1907        let state = test_state(data_store, run_store);
1908        let consumer_id = StepId::new();
1909        let mut payload = consumer_payload(&consumer_id, &run_id);
1910        assemble_step_pointers(&state, &mut payload).await;
1911
1912        let names: Vec<&str> = payload
1913            .context
1914            .as_ref()
1915            .expect("context")
1916            .steps
1917            .iter()
1918            .map(|p| p.name.as_str())
1919            .collect();
1920        assert!(!names.contains(&"consumer"), "names: {names:?}");
1921        assert!(names.contains(&"planner"), "names: {names:?}");
1922    }
1923
1924    /// Test 7 (pointer-only invariant): a `StepPointer`'s serialized JSON
1925    /// carries no preview / content-bytes field — only `name` /
1926    /// `size_bytes` / `file_path?` / `content_url` / `sha256`.
1927    #[tokio::test]
1928    async fn step_pointer_serializes_with_no_preview_or_content_bytes() {
1929        let data_store: Arc<dyn OutputStore> = Arc::new(InMemoryOutputStore::new());
1930        let run_store: Arc<dyn RunStore> = Arc::new(InMemoryRunStore::new());
1931        let task_id = TaskId::new();
1932        let run_id = RunId::new();
1933        let planner_id = StepId::new();
1934        append_final(
1935            &data_store,
1936            planner_id.as_str(),
1937            "planner",
1938            json!({"plan": "do the thing, at length".repeat(50)}),
1939        )
1940        .await;
1941        run_store
1942            .create(run_record(
1943                &task_id,
1944                &run_id,
1945                vec![step_entry(&planner_id, "planner")],
1946            ))
1947            .await
1948            .expect("create run");
1949
1950        let state = test_state(data_store, run_store);
1951        let consumer_id = StepId::new();
1952        let mut payload = consumer_payload(&consumer_id, &run_id);
1953        assemble_step_pointers(&state, &mut payload).await;
1954
1955        let steps = &payload.context.expect("context").steps;
1956        assert_eq!(steps.len(), 1);
1957        let json_value = serde_json::to_value(&steps[0]).expect("serialize StepPointer");
1958        let obj = json_value.as_object().expect("object");
1959        for forbidden in ["preview", "content", "value", "bytes"] {
1960            assert!(
1961                !obj.contains_key(forbidden),
1962                "StepPointer must not carry a {forbidden:?} field: {obj:?}"
1963            );
1964        }
1965        assert!(obj.contains_key("name"));
1966        assert!(obj.contains_key("size_bytes"));
1967        assert!(obj.contains_key("content_url"));
1968        assert!(obj.contains_key("sha256"));
1969    }
1970
1971    /// A single-step Blueprint whose `planner` agent declares
1972    /// `AgentMeta.projection_name = "plan-out"` — the `StepNaming` fixture
1973    /// for [`declared_projection_name_pointer_name_is_canonical_and_policy_matches_it`],
1974    /// mirroring `crate::projection::tests`' own
1975    /// `declared_projection_name_blueprint` helper (duplicated here rather
1976    /// than shared — this crate's established per-module test-helper
1977    /// convention).
1978    fn declared_name_bp() -> mlua_swarm::blueprint::Blueprint {
1979        use mlua_flow_ir::{Expr, Node};
1980        use mlua_swarm::blueprint::{
1981            current_schema_version, AgentDef, AgentKind, AgentMeta, Blueprint, BlueprintMetadata,
1982            CompilerHints, CompilerStrategy,
1983        };
1984        Blueprint {
1985            schema_version: current_schema_version(),
1986            id: "worker-test-declared-name-bp".into(),
1987            flow: Node::Step {
1988                ref_: "planner".to_string(),
1989                in_: Expr::Path {
1990                    at: "$.in".parse().expect("literal test path: $.in"),
1991                },
1992                out: Expr::Path {
1993                    at: "$.plan".parse().expect("literal test path: $.plan"),
1994                },
1995            },
1996            agents: vec![AgentDef {
1997                name: "planner".to_string(),
1998                kind: AgentKind::RustFn,
1999                spec: json!({"fn_id": "planner"}),
2000                profile: None,
2001                meta: Some(AgentMeta {
2002                    projection_name: Some("plan-out".to_string()),
2003                    ..Default::default()
2004                }),
2005                runner: None,
2006                runner_ref: None,
2007                verdict: None,
2008            }],
2009            operators: vec![],
2010            metas: vec![],
2011            hints: CompilerHints::default(),
2012            strategy: CompilerStrategy::default(),
2013            metadata: BlueprintMetadata::default(),
2014            spawner_hints: Default::default(),
2015            default_agent_kind: AgentKind::Operator,
2016            default_operator_kind: None,
2017            default_init_ctx: None,
2018            default_agent_ctx: None,
2019            default_context_policy: None,
2020            projection_placement: None,
2021            audits: vec![],
2022            degradation_policy: None,
2023            runners: vec![],
2024            default_runner: None,
2025            subprocesses: vec![],
2026            check_policy: None,
2027            blueprint_ref_includes: Vec::new(),
2028        }
2029    }
2030
2031    /// Test 8 (GH #23 subtask-3, declared-name E2E — Worker axis half): a
2032    /// declared `projection_name` makes `StepPointer.name` the CANONICAL
2033    /// name (not the raw `Step.ref` the Data-plane / `step_entries` still
2034    /// index by), and `ContextPolicy.steps` naming the canonical name
2035    /// matches it.
2036    #[tokio::test]
2037    async fn declared_projection_name_pointer_name_is_canonical_and_policy_matches_it() {
2038        let data_store: Arc<dyn OutputStore> = Arc::new(InMemoryOutputStore::new());
2039        let run_store: Arc<dyn RunStore> = Arc::new(InMemoryRunStore::new());
2040        let task_id = TaskId::new();
2041        let run_id = RunId::new();
2042        let planner_id = StepId::new();
2043
2044        // The Data-plane store is keyed by the CANONICAL name — GH #23
2045        // subtask-2's sink already writes it that way.
2046        append_final(
2047            &data_store,
2048            planner_id.as_str(),
2049            "plan-out",
2050            json!({"plan": "x"}),
2051        )
2052        .await;
2053        run_store
2054            .create(run_record(
2055                &task_id,
2056                &run_id,
2057                vec![step_entry(&planner_id, "planner")],
2058            ))
2059            .await
2060            .expect("create run");
2061
2062        let state = test_state(data_store, run_store);
2063
2064        // Seed the `StepNaming` table the way `Compiler::compile` +
2065        // `EngineDispatcher::dispatch` would have — the same `Arc` stashed
2066        // under every dispatched step's own id, including the FETCHING
2067        // agent's (`consumer_id`), which `assemble_step_pointers` looks up
2068        // via `Engine::step_naming_for(&payload.task_id)`.
2069        let (naming, _warnings) =
2070            mlua_swarm::core::step_naming::StepNaming::from_blueprint(&declared_name_bp())
2071                .expect("no collision");
2072        let naming = Arc::new(naming);
2073        let consumer_id = StepId::new();
2074        state
2075            .engine
2076            .with_state("test.seed_step_naming", {
2077                let naming = naming.clone();
2078                let planner_id = planner_id.clone();
2079                let consumer_id = consumer_id.clone();
2080                move |s| {
2081                    s.step_namings.insert(planner_id, naming.clone());
2082                    s.step_namings.insert(consumer_id, naming);
2083                }
2084            })
2085            .await
2086            .expect("seed step naming");
2087        state
2088            .engine
2089            .with_state("test.seed_policy", {
2090                let consumer_id = consumer_id.clone();
2091                move |s| {
2092                    s.agent_ctx.insert(
2093                        (consumer_id, 1),
2094                        mlua_swarm::core::state::AgentCtxEntry {
2095                            policy: mlua_swarm_schema::ContextPolicy {
2096                                steps: Some(vec!["plan-out".to_string()]),
2097                                ..Default::default()
2098                            },
2099                            ..Default::default()
2100                        },
2101                    );
2102                }
2103            })
2104            .await
2105            .expect("seed policy");
2106
2107        let mut payload = consumer_payload(&consumer_id, &run_id);
2108        assemble_step_pointers(&state, &mut payload).await;
2109
2110        let steps = &payload.context.expect("context").steps;
2111        assert_eq!(steps.len(), 1, "steps: {steps:?}");
2112        assert_eq!(
2113            steps[0].name, "plan-out",
2114            "StepPointer.name must be the canonical name"
2115        );
2116    }
2117
2118    // ──────────────────────────────────────────────────────────────────────
2119    // GH #31 — `/v1/worker/prompt/system` + `/v1/agents/:name/render-size`
2120    // ──────────────────────────────────────────────────────────────────────
2121
2122    /// Seeds a task + baked system prompt + a short worker handle bound to
2123    /// it, mirroring the shape `Engine::dispatch_attempt` would have
2124    /// produced (minus the parts these two routes don't touch: no real
2125    /// HMAC-signed `CapToken`, since `task_id_from_handle`'s handle → fp →
2126    /// task_id chain is what's under test, not signature verification).
2127    async fn seed_task_with_handle(
2128        state: &AppState,
2129        task_id: &StepId,
2130        agent: &str,
2131        attempt: u32,
2132        system: Option<String>,
2133    ) -> String {
2134        let handle = format!("wh-{}", mlua_swarm::types::secure_hex(4));
2135        let task_id = task_id.clone();
2136        let agent = agent.to_string();
2137        let handle_clone = handle.clone();
2138        state
2139            .engine
2140            .with_state("test.seed_task_with_handle", move |s| {
2141                let mut task = mlua_swarm::core::state::TaskState::new(
2142                    task_id.clone(),
2143                    mlua_swarm::core::state::TaskSpec {
2144                        agent: agent.clone(),
2145                        initial_directive: json!("x"),
2146                        step_ctx: None,
2147                        check_policy: None,
2148                    },
2149                );
2150                task.attempt = attempt;
2151                s.tasks.insert(task_id.clone(), task);
2152                s.systems.insert((task_id.clone(), attempt), system);
2153                let token = CapToken {
2154                    agent_id: agent,
2155                    role: mlua_swarm::Role::Worker,
2156                    scopes: vec!["*".to_string()],
2157                    issued_at: 0,
2158                    expire_at: u64::MAX,
2159                    max_uses: None,
2160                    nonce: format!("test-nonce-{task_id}"),
2161                    sig_hex: String::new(),
2162                };
2163                let fp = token.fingerprint();
2164                s.tokens.insert(
2165                    fp.clone(),
2166                    mlua_swarm::core::state::CapTokenRecord {
2167                        token,
2168                        uses_left: None,
2169                        revoked: false,
2170                        task_id: Some(task_id),
2171                    },
2172                );
2173                s.worker_handles.insert(handle_clone, fp);
2174            })
2175            .await
2176            .expect("seed_task_with_handle");
2177        handle
2178    }
2179
2180    fn bearer_headers(handle: &str) -> HeaderMap {
2181        let mut headers = HeaderMap::new();
2182        headers.insert(
2183            AUTHORIZATION,
2184            format!("Bearer {handle}").parse().expect("header value"),
2185        );
2186        headers
2187    }
2188
2189    /// `GET /v1/worker/prompt/system` returns the exact raw baked bytes
2190    /// (not JSON-wrapped) with `Content-Type: text/plain`, for the
2191    /// `(task_id, attempt)` the handle is bound to.
2192    #[tokio::test]
2193    async fn worker_prompt_system_returns_raw_bytes_for_baked_system() {
2194        let data_store: Arc<dyn OutputStore> = Arc::new(InMemoryOutputStore::new());
2195        let run_store: Arc<dyn RunStore> = Arc::new(InMemoryRunStore::new());
2196        let state = test_state(data_store, run_store);
2197        let task_id = StepId::new();
2198        let rendered = "# Hello\n\nThis is the baked system prompt.".to_string();
2199        let handle =
2200            seed_task_with_handle(&state, &task_id, "planner", 1, Some(rendered.clone())).await;
2201
2202        let resp = worker_prompt_system(
2203            State(state.clone()),
2204            bearer_headers(&handle),
2205            Query(PromptSystemQuery {
2206                task_id: task_id.clone(),
2207                attempt: 1,
2208            }),
2209        )
2210        .await
2211        .expect("worker_prompt_system")
2212        .into_response();
2213
2214        assert_eq!(resp.status(), StatusCode::OK);
2215        let content_type = resp
2216            .headers()
2217            .get(header::CONTENT_TYPE)
2218            .expect("content-type header")
2219            .to_str()
2220            .expect("ascii");
2221        assert_eq!(content_type, "text/plain; charset=utf-8");
2222        let body_bytes = axum::body::to_bytes(resp.into_body(), usize::MAX)
2223            .await
2224            .expect("body bytes");
2225        assert_eq!(body_bytes.as_ref(), rendered.as_bytes());
2226    }
2227
2228    /// No baked system for the given `(task_id, attempt)` → 404, not a
2229    /// panic or a 200-with-empty-body.
2230    #[tokio::test]
2231    async fn worker_prompt_system_404s_when_no_baked_system() {
2232        let data_store: Arc<dyn OutputStore> = Arc::new(InMemoryOutputStore::new());
2233        let run_store: Arc<dyn RunStore> = Arc::new(InMemoryRunStore::new());
2234        let state = test_state(data_store, run_store);
2235        let task_id = StepId::new();
2236        let handle = seed_task_with_handle(&state, &task_id, "planner", 1, None).await;
2237
2238        let result = worker_prompt_system(
2239            State(state.clone()),
2240            bearer_headers(&handle),
2241            Query(PromptSystemQuery {
2242                task_id: task_id.clone(),
2243                attempt: 1,
2244            }),
2245        )
2246        .await;
2247        let err = match result {
2248            Ok(_) => panic!("expected 404 ApiError, got Ok"),
2249            Err(e) => e,
2250        };
2251        assert_eq!(err.into_response().status(), StatusCode::NOT_FOUND);
2252    }
2253
2254    /// A handle bound to a different task than the one requested must be
2255    /// rejected (400) — this is the same cross-check `worker_prompt` does.
2256    #[tokio::test]
2257    async fn worker_prompt_system_rejects_handle_task_mismatch() {
2258        let data_store: Arc<dyn OutputStore> = Arc::new(InMemoryOutputStore::new());
2259        let run_store: Arc<dyn RunStore> = Arc::new(InMemoryRunStore::new());
2260        let state = test_state(data_store, run_store);
2261        let task_id = StepId::new();
2262        let other_task_id = StepId::new();
2263        let handle =
2264            seed_task_with_handle(&state, &task_id, "planner", 1, Some("x".to_string())).await;
2265
2266        let result = worker_prompt_system(
2267            State(state.clone()),
2268            bearer_headers(&handle),
2269            Query(PromptSystemQuery {
2270                task_id: other_task_id,
2271                attempt: 1,
2272            }),
2273        )
2274        .await;
2275        let err = match result {
2276            Ok(_) => panic!("expected 400 ApiError for task mismatch, got Ok"),
2277            Err(e) => e,
2278        };
2279        assert_eq!(err.into_response().status(), StatusCode::BAD_REQUEST);
2280    }
2281
2282    /// `GET /v1/agents/:name/render-size` requires no auth, and reports
2283    /// `last_rendered_bytes: null` for an agent that has never had a
2284    /// `system_prompt` baked — a normal 200, not a 404.
2285    #[tokio::test]
2286    async fn agent_render_size_returns_null_for_unknown_agent() {
2287        let data_store: Arc<dyn OutputStore> = Arc::new(InMemoryOutputStore::new());
2288        let run_store: Arc<dyn RunStore> = Arc::new(InMemoryRunStore::new());
2289        let state = test_state(data_store, run_store);
2290
2291        let Json(body) = agent_render_size(
2292            State(state.clone()),
2293            axum::extract::Path("never-dispatched".to_string()),
2294        )
2295        .await;
2296        assert_eq!(body.agent, "never-dispatched");
2297        assert_eq!(body.last_rendered_bytes, None);
2298    }
2299
2300    /// Once `bake_worker_system_prompt` has recorded a render size for an
2301    /// agent, the route reports the most-recently-observed value.
2302    #[tokio::test]
2303    async fn agent_render_size_reports_last_rendered_bytes() {
2304        let data_store: Arc<dyn OutputStore> = Arc::new(InMemoryOutputStore::new());
2305        let run_store: Arc<dyn RunStore> = Arc::new(InMemoryRunStore::new());
2306        let state = test_state(data_store, run_store);
2307        let task_id = StepId::new();
2308        state
2309            .engine
2310            .with_state("test.seed_agent_ctx_for_bake", {
2311                let task_id = task_id.clone();
2312                move |s| {
2313                    s.tasks.insert(
2314                        task_id.clone(),
2315                        mlua_swarm::core::state::TaskState::new(
2316                            task_id,
2317                            mlua_swarm::core::state::TaskSpec {
2318                                agent: "coder".to_string(),
2319                                initial_directive: json!("x"),
2320                                step_ctx: None,
2321                                check_policy: None,
2322                            },
2323                        ),
2324                    );
2325                }
2326            })
2327            .await
2328            .expect("seed task");
2329        state
2330            .engine
2331            .bake_worker_system_prompt(&task_id, 1, Some("z".repeat(42)))
2332            .await
2333            .expect("bake_worker_system_prompt");
2334
2335        let Json(body) = agent_render_size(
2336            State(state.clone()),
2337            axum::extract::Path("coder".to_string()),
2338        )
2339        .await;
2340        assert_eq!(body.agent, "coder");
2341        assert_eq!(body.last_rendered_bytes, Some(42));
2342    }
2343
2344    // ──────────────────────────────────────────────────────────────────────
2345    // GH #36 ST1 — `POST /v1/worker/artifact`
2346    // ──────────────────────────────────────────────────────────────────────
2347
2348    /// A valid `?name=` + short-handle Bearer stages the raw body (trailing
2349    /// whitespace trimmed, same as `worker_submit`) as an `Artifact` on the
2350    /// task's current-attempt tail, and returns `204 No Content`.
2351    #[tokio::test]
2352    async fn worker_artifact_stages_and_204s_for_valid_request() {
2353        let data_store: Arc<dyn OutputStore> = Arc::new(InMemoryOutputStore::new());
2354        let run_store: Arc<dyn RunStore> = Arc::new(InMemoryRunStore::new());
2355        let state = test_state(data_store, run_store);
2356        let task_id = StepId::new();
2357        let handle = seed_task_with_handle(&state, &task_id, "planner", 1, None).await;
2358
2359        let status = worker_artifact(
2360            State(state.clone()),
2361            bearer_headers(&handle),
2362            Query(ArtifactQuery {
2363                name: "summary".to_string(),
2364            }),
2365            axum::body::Bytes::from_static(b"hello artifact\n"),
2366        )
2367        .await
2368        .expect("worker_artifact");
2369        assert_eq!(status, StatusCode::NO_CONTENT);
2370
2371        let tail = state.engine.output_tail(&task_id, 1).await;
2372        assert_eq!(tail.len(), 1, "tail: {tail:?}");
2373        match &tail[0] {
2374            OutputEvent::Artifact { name, content } => {
2375                assert_eq!(name, "summary");
2376                match content {
2377                    ContentRef::Inline { value } => {
2378                        assert_eq!(value, &json!("hello artifact"));
2379                    }
2380                    other => panic!("expected Inline content, got {other:?}"),
2381                }
2382            }
2383            other => panic!("expected Artifact event, got {other:?}"),
2384        }
2385    }
2386
2387    /// `?name=` missing entirely → axum's `Query` extractor rejection
2388    /// (400), not a panic. `Query<ArtifactQuery>` is constructed directly
2389    /// in this test (mirroring the other handlers' unit style, which call
2390    /// the handler fn with an already-extracted `Query`) — an empty `name`
2391    /// is exercised separately below since that case is NOT caught by the
2392    /// extractor and must be checked in the handler body.
2393    #[tokio::test]
2394    async fn worker_artifact_rejects_blank_name() {
2395        let data_store: Arc<dyn OutputStore> = Arc::new(InMemoryOutputStore::new());
2396        let run_store: Arc<dyn RunStore> = Arc::new(InMemoryRunStore::new());
2397        let state = test_state(data_store, run_store);
2398        let task_id = StepId::new();
2399        let handle = seed_task_with_handle(&state, &task_id, "planner", 1, None).await;
2400
2401        let result = worker_artifact(
2402            State(state.clone()),
2403            bearer_headers(&handle),
2404            Query(ArtifactQuery {
2405                name: "   ".to_string(),
2406            }),
2407            axum::body::Bytes::from_static(b"x"),
2408        )
2409        .await;
2410        let err = match result {
2411            Ok(_) => panic!("expected 400 ApiError for blank name, got Ok"),
2412            Err(e) => e,
2413        };
2414        assert_eq!(err.into_response().status(), StatusCode::BAD_REQUEST);
2415
2416        // Nothing was staged.
2417        assert!(state.engine.output_tail(&task_id, 1).await.is_empty());
2418    }
2419
2420    /// Staging the same `name` twice within one attempt is last-write-wins
2421    /// on the folded value (`fold_final_and_parts` in `mlua_swarm::core::
2422    /// engine`) — this test only asserts the raw tail carries both events
2423    /// in order (the fold itself is covered by that crate's own unit
2424    /// tests); `Engine::stage_worker_artifact_trusted`'s doc.
2425    #[tokio::test]
2426    async fn worker_artifact_staging_same_name_twice_appends_both_events_in_order() {
2427        let data_store: Arc<dyn OutputStore> = Arc::new(InMemoryOutputStore::new());
2428        let run_store: Arc<dyn RunStore> = Arc::new(InMemoryRunStore::new());
2429        let state = test_state(data_store, run_store);
2430        let task_id = StepId::new();
2431        let handle = seed_task_with_handle(&state, &task_id, "planner", 1, None).await;
2432
2433        for body in [b"first".as_slice(), b"second".as_slice()] {
2434            worker_artifact(
2435                State(state.clone()),
2436                bearer_headers(&handle),
2437                Query(ArtifactQuery {
2438                    name: "a".to_string(),
2439                }),
2440                axum::body::Bytes::copy_from_slice(body),
2441            )
2442            .await
2443            .expect("worker_artifact");
2444        }
2445
2446        let tail = state.engine.output_tail(&task_id, 1).await;
2447        assert_eq!(tail.len(), 2, "tail: {tail:?}");
2448        let values: Vec<&str> = tail
2449            .iter()
2450            .map(|ev| match ev {
2451                OutputEvent::Artifact {
2452                    content: ContentRef::Inline { value },
2453                    ..
2454                } => value.as_str().expect("string value"),
2455                other => panic!("expected Artifact/Inline event, got {other:?}"),
2456            })
2457            .collect();
2458        assert_eq!(values, vec!["first", "second"]);
2459    }
2460
2461    // ──────────────────────────────────────────────────────────────────
2462    // GH #37 — terminal-run guard (`reject_if_run_terminal`)
2463    // ──────────────────────────────────────────────────────────────────
2464
2465    /// Links a seeded dispatch task to a Run the same way
2466    /// `AgentContextMiddleware` does at spawn time: an `agent_ctx` entry
2467    /// whose view carries the `run_id`.
2468    async fn link_task_to_run(state: &AppState, task_id: &StepId, attempt: u32, run_id: &RunId) {
2469        let tid = task_id.clone();
2470        let rid_str = run_id.to_string();
2471        state
2472            .engine
2473            .with_state("test.link_task_to_run", move |s| {
2474                let mut entry = mlua_swarm::core::state::AgentCtxEntry::default();
2475                entry.view.run_id = Some(rid_str);
2476                s.agent_ctx.insert((tid, attempt), entry);
2477            })
2478            .await
2479            .expect("link_task_to_run");
2480    }
2481
2482    /// GH #37: a submit / artifact addressed at a Run that already
2483    /// reached a terminal status must be rejected with `410 Gone` — the
2484    /// flow-eval driver for that Run is gone, so a silent `204` here
2485    /// would orphan the worker's output.
2486    #[tokio::test]
2487    async fn submit_and_artifact_against_terminal_run_return_410() {
2488        let data_store: Arc<dyn OutputStore> = Arc::new(InMemoryOutputStore::new());
2489        let run_store: Arc<dyn RunStore> = Arc::new(InMemoryRunStore::new());
2490        let state = test_state(data_store, run_store.clone());
2491        let task_id = StepId::new();
2492        let handle = seed_task_with_handle(&state, &task_id, "planner", 1, None).await;
2493
2494        let owner_task = TaskId::new();
2495        let run_id = RunId::new();
2496        let mut rec = run_record(&owner_task, &run_id, vec![]);
2497        rec.status = RunStatus::Failed;
2498        run_store.create(rec).await.expect("run create");
2499        link_task_to_run(&state, &task_id, 1, &run_id).await;
2500
2501        let err = worker_submit(
2502            State(state.clone()),
2503            bearer_headers(&handle),
2504            Query(SubmitQuery {
2505                ok: None,
2506                verdict: None,
2507            }),
2508            axum::body::Bytes::from_static(b"LATE OUTPUT"),
2509        )
2510        .await
2511        .expect_err("a submit against a Failed run must be rejected");
2512        assert_eq!(err.status, StatusCode::GONE);
2513        assert!(
2514            err.message.contains(&run_id.to_string()),
2515            "the 410 must name the terminal run: {}",
2516            err.message
2517        );
2518
2519        let err = worker_artifact(
2520            State(state.clone()),
2521            bearer_headers(&handle),
2522            Query(ArtifactQuery {
2523                name: "part.md".to_string(),
2524            }),
2525            axum::body::Bytes::from_static(b"LATE PART"),
2526        )
2527        .await
2528        .expect_err("an artifact staged against a Failed run must be rejected");
2529        assert_eq!(err.status, StatusCode::GONE);
2530
2531        // The rejected values must not have reached the output tail.
2532        let tail = state.engine.output_tail(&task_id, 1).await;
2533        assert!(tail.is_empty(), "rejected submits must not land: {tail:?}");
2534    }
2535
2536    /// GH #37 fail-open contract: the guard must never turn a
2537    /// would-have-succeeded submit into a failure — no run linkage at
2538    /// all, an unknown Run, and a live (`Running`) Run all pass.
2539    #[tokio::test]
2540    async fn terminal_run_guard_is_fail_open() {
2541        let data_store: Arc<dyn OutputStore> = Arc::new(InMemoryOutputStore::new());
2542        let run_store: Arc<dyn RunStore> = Arc::new(InMemoryRunStore::new());
2543        let state = test_state(data_store, run_store.clone());
2544        let task_id = StepId::new();
2545        seed_task_with_handle(&state, &task_id, "planner", 1, None).await;
2546
2547        // (a) No agent-ctx linkage at all (pre-run-tracking dispatch).
2548        reject_if_run_terminal(&state, &task_id, 1)
2549            .await
2550            .expect("no linkage must fail open");
2551
2552        // (b) Linked to a Run the store does not know.
2553        let unknown_run = RunId::new();
2554        link_task_to_run(&state, &task_id, 1, &unknown_run).await;
2555        reject_if_run_terminal(&state, &task_id, 1)
2556            .await
2557            .expect("unknown run must fail open");
2558
2559        // (c) Linked to a live Run.
2560        let owner_task = TaskId::new();
2561        let live_run = RunId::new();
2562        run_store
2563            .create(run_record(&owner_task, &live_run, vec![]))
2564            .await
2565            .expect("run create");
2566        link_task_to_run(&state, &task_id, 1, &live_run).await;
2567        reject_if_run_terminal(&state, &task_id, 1)
2568            .await
2569            .expect("a Running run must pass the guard");
2570    }
2571
2572    // ──────────────────────────────────────────────────────────────────
2573    // GH #32 — `POST /v1/worker/degradation`
2574    // ──────────────────────────────────────────────────────────────────
2575
2576    fn degradation_body(tool: &str, note: Option<&str>) -> DegradationBody {
2577        DegradationBody {
2578            tool: tool.to_string(),
2579            error: "boom".to_string(),
2580            fallback: "used cached value".to_string(),
2581            note: note.map(str::to_string),
2582        }
2583    }
2584
2585    /// [`link_task_to_run`] plus the `view.agent` name — production's
2586    /// `AgentContextMiddleware` sets both fields on the same `agent_ctx`
2587    /// entry; the shared GH #37 helper only needed `run_id`, so this
2588    /// sibling fills in `agent` too for tests that assert on the
2589    /// server-injected `step_ref`.
2590    async fn link_task_to_run_with_agent(
2591        state: &AppState,
2592        task_id: &StepId,
2593        attempt: u32,
2594        run_id: &RunId,
2595        agent: &str,
2596    ) {
2597        let tid = task_id.clone();
2598        let rid_str = run_id.to_string();
2599        let agent = agent.to_string();
2600        state
2601            .engine
2602            .with_state("test.link_task_to_run_with_agent", move |s| {
2603                let mut entry = mlua_swarm::core::state::AgentCtxEntry::default();
2604                entry.view.run_id = Some(rid_str);
2605                entry.view.agent = agent;
2606                s.agent_ctx.insert((tid, attempt), entry);
2607            })
2608            .await
2609            .expect("link_task_to_run_with_agent");
2610    }
2611
2612    /// A worker-reported degradation is persisted to the linked Run's
2613    /// `degradations` with the server-injected `step_ref` / `attempt` /
2614    /// `at` fields filled in — the client body never supplies any of the
2615    /// three.
2616    #[tokio::test]
2617    async fn worker_degradation_persists_entry_when_run_tracked() {
2618        let data_store: Arc<dyn OutputStore> = Arc::new(InMemoryOutputStore::new());
2619        let run_store: Arc<dyn RunStore> = Arc::new(InMemoryRunStore::new());
2620        let state = test_state(data_store, run_store.clone());
2621        let task_id = StepId::new();
2622        let handle = seed_task_with_handle(&state, &task_id, "planner", 1, None).await;
2623
2624        let owner_task = TaskId::new();
2625        let run_id = RunId::new();
2626        run_store
2627            .create(run_record(&owner_task, &run_id, vec![]))
2628            .await
2629            .expect("run create");
2630        link_task_to_run_with_agent(&state, &task_id, 1, &run_id, "planner").await;
2631
2632        let status = worker_degradation(
2633            State(state.clone()),
2634            bearer_headers(&handle),
2635            Json(degradation_body("web_search", Some("rate limited"))),
2636        )
2637        .await
2638        .expect("worker_degradation");
2639        assert_eq!(status, StatusCode::NO_CONTENT);
2640
2641        let rec = run_store.get(&run_id).await.expect("run get");
2642        assert_eq!(
2643            rec.degradations.len(),
2644            1,
2645            "degradations: {:?}",
2646            rec.degradations
2647        );
2648        let entry = &rec.degradations[0];
2649        assert_eq!(entry.tool, "web_search");
2650        assert_eq!(entry.error, "boom");
2651        assert_eq!(entry.fallback, "used cached value");
2652        assert_eq!(entry.note.as_deref(), Some("rate limited"));
2653        assert_eq!(entry.step_ref.as_deref(), Some("planner"));
2654        assert_eq!(entry.attempt, Some(1));
2655        assert!(entry.at > 0, "at must be a real timestamp: {}", entry.at);
2656    }
2657
2658    /// Two entries POSTed in sequence are appended in order.
2659    #[tokio::test]
2660    async fn worker_degradation_appends_in_order() {
2661        let data_store: Arc<dyn OutputStore> = Arc::new(InMemoryOutputStore::new());
2662        let run_store: Arc<dyn RunStore> = Arc::new(InMemoryRunStore::new());
2663        let state = test_state(data_store, run_store.clone());
2664        let task_id = StepId::new();
2665        let handle = seed_task_with_handle(&state, &task_id, "planner", 1, None).await;
2666
2667        let owner_task = TaskId::new();
2668        let run_id = RunId::new();
2669        run_store
2670            .create(run_record(&owner_task, &run_id, vec![]))
2671            .await
2672            .expect("run create");
2673        link_task_to_run(&state, &task_id, 1, &run_id).await;
2674
2675        for tool in ["first_tool", "second_tool"] {
2676            worker_degradation(
2677                State(state.clone()),
2678                bearer_headers(&handle),
2679                Json(degradation_body(tool, None)),
2680            )
2681            .await
2682            .expect("worker_degradation");
2683        }
2684
2685        let rec = run_store.get(&run_id).await.expect("run get");
2686        let tools: Vec<&str> = rec.degradations.iter().map(|e| e.tool.as_str()).collect();
2687        assert_eq!(tools, vec!["first_tool", "second_tool"]);
2688    }
2689
2690    /// A task whose `agent_ctx` carries no Run linkage (pre-run-tracking
2691    /// dispatch) silently 204s — nothing to append to, and this must not
2692    /// surface as a client error.
2693    #[tokio::test]
2694    async fn worker_degradation_silent_ok_when_no_run_tracked() {
2695        let data_store: Arc<dyn OutputStore> = Arc::new(InMemoryOutputStore::new());
2696        let run_store: Arc<dyn RunStore> = Arc::new(InMemoryRunStore::new());
2697        let state = test_state(data_store, run_store);
2698        let task_id = StepId::new();
2699        let handle = seed_task_with_handle(&state, &task_id, "planner", 1, None).await;
2700
2701        let status = worker_degradation(
2702            State(state.clone()),
2703            bearer_headers(&handle),
2704            Json(degradation_body("some_tool", None)),
2705        )
2706        .await
2707        .expect("worker_degradation must not error on missing run linkage");
2708        assert_eq!(status, StatusCode::NO_CONTENT);
2709    }
2710
2711    /// GH #37 terminal-run guard applies to the degradation channel too — a
2712    /// dead Run must not accumulate signals.
2713    #[tokio::test]
2714    async fn worker_degradation_rejects_terminal_run() {
2715        let data_store: Arc<dyn OutputStore> = Arc::new(InMemoryOutputStore::new());
2716        let run_store: Arc<dyn RunStore> = Arc::new(InMemoryRunStore::new());
2717        let state = test_state(data_store, run_store.clone());
2718        let task_id = StepId::new();
2719        let handle = seed_task_with_handle(&state, &task_id, "planner", 1, None).await;
2720
2721        let owner_task = TaskId::new();
2722        let run_id = RunId::new();
2723        let mut rec = run_record(&owner_task, &run_id, vec![]);
2724        rec.status = RunStatus::Done;
2725        run_store.create(rec).await.expect("run create");
2726        link_task_to_run(&state, &task_id, 1, &run_id).await;
2727
2728        let err = worker_degradation(
2729            State(state.clone()),
2730            bearer_headers(&handle),
2731            Json(degradation_body("some_tool", None)),
2732        )
2733        .await
2734        .expect_err("a degradation against a Done run must be rejected");
2735        assert_eq!(err.status, StatusCode::GONE);
2736
2737        let rec = run_store.get(&run_id).await.expect("run get");
2738        assert!(
2739            rec.degradations.is_empty(),
2740            "rejected degradation must not land: {:?}",
2741            rec.degradations
2742        );
2743    }
2744
2745    // ──────────────────────────────────────────────────────────────────
2746    // GH #42 — `@file:<abs-path>` sentinel resolution in `worker_submit`
2747    // / `worker_artifact`. Guards each verified independently: sentinel
2748    // resolves to the file's trimmed contents; path outside `work_dir`,
2749    // missing file, oversized file, and non-sentinel bodies each get the
2750    // documented behavior.
2751    // ──────────────────────────────────────────────────────────────────
2752
2753    /// Seeds an `agent_ctx` entry whose view carries `work_dir` and, when
2754    /// `allow_file_submit` is `Some`, that value under the GH #43
2755    /// [`FILE_SENTINEL_ALLOW_KEY`] in `view.extra` — matching the shape
2756    /// `AgentContextMiddleware` writes at spawn time. Sentinel resolution
2757    /// requires both the `work_dir` and the strict `Bool(true)` opt-in.
2758    async fn seed_work_dir(
2759        state: &AppState,
2760        task_id: &StepId,
2761        attempt: u32,
2762        work_dir: &str,
2763        allow_file_submit: Option<Value>,
2764    ) {
2765        let tid = task_id.clone();
2766        let work_dir = work_dir.to_string();
2767        state
2768            .engine
2769            .with_state("test.seed_work_dir", move |s| {
2770                let mut entry = mlua_swarm::core::state::AgentCtxEntry::default();
2771                entry.view.work_dir = Some(work_dir);
2772                if let Some(v) = allow_file_submit {
2773                    entry
2774                        .view
2775                        .extra
2776                        .insert(FILE_SENTINEL_ALLOW_KEY.to_string(), v);
2777                }
2778                s.agent_ctx.insert((tid, attempt), entry);
2779            })
2780            .await
2781            .expect("seed_work_dir");
2782    }
2783
2784    /// Sentinel body `@file:<abs-path>` resolves to the file's trimmed
2785    /// contents and reaches the `OutputStore` via the normal Final-append
2786    /// path — same 204 the inline path returns.
2787    #[tokio::test]
2788    async fn worker_submit_resolves_file_sentinel_under_work_dir() {
2789        let data_store: Arc<dyn OutputStore> = Arc::new(InMemoryOutputStore::new());
2790        let run_store: Arc<dyn RunStore> = Arc::new(InMemoryRunStore::new());
2791        let state = test_state(data_store.clone(), run_store);
2792        let task_id = StepId::new();
2793        let handle = seed_task_with_handle(&state, &task_id, "planner", 1, None).await;
2794
2795        let tmp = tempfile::tempdir().expect("tempdir");
2796        let work_dir = tmp.path().to_path_buf();
2797        seed_work_dir(
2798            &state,
2799            &task_id,
2800            1,
2801            work_dir.to_str().expect("work_dir utf-8"),
2802            Some(Value::Bool(true)),
2803        )
2804        .await;
2805
2806        let payload_path = work_dir.join("scout.md");
2807        let payload = "## Context Package (broad)\n\nlarge body content\n";
2808        tokio::fs::write(&payload_path, payload)
2809            .await
2810            .expect("write payload");
2811        let body = format!(
2812            "@file:{}",
2813            payload_path.to_str().expect("payload path utf-8")
2814        );
2815
2816        let status = worker_submit(
2817            State(state.clone()),
2818            bearer_headers(&handle),
2819            Query(SubmitQuery {
2820                ok: None,
2821                verdict: None,
2822            }),
2823            axum::body::Bytes::from(body),
2824        )
2825        .await
2826        .expect("worker_submit sentinel");
2827        assert_eq!(status, StatusCode::NO_CONTENT);
2828
2829        // Final event lands with the file's trimmed contents on
2830        // `EngineState.output_store` (the in-memory tail
2831        // `submit_worker_result_trusted` writes to).
2832        let tid = task_id.clone();
2833        let value = state
2834            .engine
2835            .with_state("test.inspect_output_store", move |s| {
2836                s.output_store.get(&(tid.clone(), 1)).and_then(|evs| {
2837                    evs.iter().find_map(|ev| match ev {
2838                        OutputEvent::Final {
2839                            content: ContentRef::Inline { value },
2840                            ..
2841                        } => Some(value.clone()),
2842                        _ => None,
2843                    })
2844                })
2845            })
2846            .await
2847            .expect("with_state")
2848            .expect("Final event present");
2849        assert_eq!(value, Value::String(payload.trim_end().to_string()));
2850    }
2851
2852    /// A non-sentinel body is passed through byte-for-byte (pre-#42
2853    /// callers see zero behavior change).
2854    #[tokio::test]
2855    async fn worker_submit_passes_non_sentinel_body_unchanged() {
2856        let data_store: Arc<dyn OutputStore> = Arc::new(InMemoryOutputStore::new());
2857        let run_store: Arc<dyn RunStore> = Arc::new(InMemoryRunStore::new());
2858        let state = test_state(data_store.clone(), run_store);
2859        let task_id = StepId::new();
2860        let handle = seed_task_with_handle(&state, &task_id, "planner", 1, None).await;
2861        // No agent_ctx / work_dir seeded — the inline path must not
2862        // require one.
2863
2864        let status = worker_submit(
2865            State(state.clone()),
2866            bearer_headers(&handle),
2867            Query(SubmitQuery {
2868                ok: None,
2869                verdict: None,
2870            }),
2871            axum::body::Bytes::from_static(b"DONE yes=1 maybe=0 no=0"),
2872        )
2873        .await
2874        .expect("worker_submit inline");
2875        assert_eq!(status, StatusCode::NO_CONTENT);
2876
2877        let tid = task_id.clone();
2878        let value = state
2879            .engine
2880            .with_state("test.inspect_output_store", move |s| {
2881                s.output_store.get(&(tid.clone(), 1)).and_then(|evs| {
2882                    evs.iter().find_map(|ev| match ev {
2883                        OutputEvent::Final {
2884                            content: ContentRef::Inline { value },
2885                            ..
2886                        } => Some(value.clone()),
2887                        _ => None,
2888                    })
2889                })
2890            })
2891            .await
2892            .expect("with_state")
2893            .expect("Final event present");
2894        assert_eq!(value, Value::String("DONE yes=1 maybe=0 no=0".to_string()));
2895    }
2896
2897    /// Sentinel with a path outside the task's `work_dir` (`..`-escape
2898    /// via a sibling tempdir) → `400`. `canonicalize` collapses the
2899    /// `..`, so a symlink pointing outside the allowlist would be caught
2900    /// by the same check.
2901    #[tokio::test]
2902    async fn worker_submit_rejects_sentinel_path_outside_work_dir() {
2903        let data_store: Arc<dyn OutputStore> = Arc::new(InMemoryOutputStore::new());
2904        let run_store: Arc<dyn RunStore> = Arc::new(InMemoryRunStore::new());
2905        let state = test_state(data_store, run_store);
2906        let task_id = StepId::new();
2907        let handle = seed_task_with_handle(&state, &task_id, "planner", 1, None).await;
2908
2909        let allowed = tempfile::tempdir().expect("allowed tempdir");
2910        let outside = tempfile::tempdir().expect("outside tempdir");
2911        seed_work_dir(
2912            &state,
2913            &task_id,
2914            1,
2915            allowed.path().to_str().expect("utf-8"),
2916            Some(Value::Bool(true)),
2917        )
2918        .await;
2919
2920        let outside_file = outside.path().join("leak.md");
2921        tokio::fs::write(&outside_file, b"outside content")
2922            .await
2923            .expect("write outside");
2924        let body = format!(
2925            "@file:{}",
2926            outside_file.to_str().expect("outside path utf-8")
2927        );
2928
2929        let err = worker_submit(
2930            State(state.clone()),
2931            bearer_headers(&handle),
2932            Query(SubmitQuery {
2933                ok: None,
2934                verdict: None,
2935            }),
2936            axum::body::Bytes::from(body),
2937        )
2938        .await
2939        .expect_err("outside-work_dir sentinel must be rejected");
2940        assert_eq!(err.status, StatusCode::BAD_REQUEST);
2941    }
2942
2943    /// Sentinel pointing at a non-existent file → `404`.
2944    #[tokio::test]
2945    async fn worker_submit_rejects_sentinel_missing_file() {
2946        let data_store: Arc<dyn OutputStore> = Arc::new(InMemoryOutputStore::new());
2947        let run_store: Arc<dyn RunStore> = Arc::new(InMemoryRunStore::new());
2948        let state = test_state(data_store, run_store);
2949        let task_id = StepId::new();
2950        let handle = seed_task_with_handle(&state, &task_id, "planner", 1, None).await;
2951
2952        let tmp = tempfile::tempdir().expect("tempdir");
2953        seed_work_dir(
2954            &state,
2955            &task_id,
2956            1,
2957            tmp.path().to_str().expect("utf-8"),
2958            Some(Value::Bool(true)),
2959        )
2960        .await;
2961        let missing = tmp.path().join("does-not-exist.md");
2962        let body = format!("@file:{}", missing.to_str().expect("utf-8"));
2963
2964        let err = worker_submit(
2965            State(state.clone()),
2966            bearer_headers(&handle),
2967            Query(SubmitQuery {
2968                ok: None,
2969                verdict: None,
2970            }),
2971            axum::body::Bytes::from(body),
2972        )
2973        .await
2974        .expect_err("missing-file sentinel must be rejected");
2975        assert_eq!(err.status, StatusCode::NOT_FOUND);
2976    }
2977
2978    /// Sentinel body with a relative path → `400` before any FS lookup.
2979    #[tokio::test]
2980    async fn worker_submit_rejects_sentinel_relative_path() {
2981        let data_store: Arc<dyn OutputStore> = Arc::new(InMemoryOutputStore::new());
2982        let run_store: Arc<dyn RunStore> = Arc::new(InMemoryRunStore::new());
2983        let state = test_state(data_store, run_store);
2984        let task_id = StepId::new();
2985        let handle = seed_task_with_handle(&state, &task_id, "planner", 1, None).await;
2986
2987        let err = worker_submit(
2988            State(state.clone()),
2989            bearer_headers(&handle),
2990            Query(SubmitQuery {
2991                ok: None,
2992                verdict: None,
2993            }),
2994            axum::body::Bytes::from_static(b"@file:relative/path.md"),
2995        )
2996        .await
2997        .expect_err("relative-path sentinel must be rejected");
2998        assert_eq!(err.status, StatusCode::BAD_REQUEST);
2999    }
3000
3001    /// Sentinel body when the task has no `AgentContextView` (spawn
3002    /// didn't run through `AgentContextMiddleware`) → `400`. This is the
3003    /// documented pre-condition for sentinel use.
3004    #[tokio::test]
3005    async fn worker_submit_rejects_sentinel_without_agent_context_view() {
3006        let data_store: Arc<dyn OutputStore> = Arc::new(InMemoryOutputStore::new());
3007        let run_store: Arc<dyn RunStore> = Arc::new(InMemoryRunStore::new());
3008        let state = test_state(data_store, run_store);
3009        let task_id = StepId::new();
3010        let handle = seed_task_with_handle(&state, &task_id, "planner", 1, None).await;
3011        // No seed_work_dir — the agent_ctx map has no entry for this task.
3012
3013        let err = worker_submit(
3014            State(state.clone()),
3015            bearer_headers(&handle),
3016            Query(SubmitQuery {
3017                ok: None,
3018                verdict: None,
3019            }),
3020            axum::body::Bytes::from_static(b"@file:/tmp/anywhere.md"),
3021        )
3022        .await
3023        .expect_err("missing AgentContextView must reject sentinel");
3024        assert_eq!(err.status, StatusCode::BAD_REQUEST);
3025    }
3026
3027    /// The same sentinel form works on `POST /v1/worker/artifact` — the
3028    /// artifact endpoint shares the resolver with `worker_submit`, so the
3029    /// resolved file contents land under the artifact's `name` key.
3030    #[tokio::test]
3031    async fn worker_artifact_resolves_file_sentinel_under_work_dir() {
3032        let data_store: Arc<dyn OutputStore> = Arc::new(InMemoryOutputStore::new());
3033        let run_store: Arc<dyn RunStore> = Arc::new(InMemoryRunStore::new());
3034        let state = test_state(data_store, run_store);
3035        let task_id = StepId::new();
3036        let handle = seed_task_with_handle(&state, &task_id, "planner", 1, None).await;
3037
3038        let tmp = tempfile::tempdir().expect("tempdir");
3039        seed_work_dir(
3040            &state,
3041            &task_id,
3042            1,
3043            tmp.path().to_str().expect("utf-8"),
3044            Some(Value::Bool(true)),
3045        )
3046        .await;
3047
3048        let payload_path = tmp.path().join("part.md");
3049        let payload = "artifact part body\n";
3050        tokio::fs::write(&payload_path, payload)
3051            .await
3052            .expect("write payload");
3053        let body = format!("@file:{}", payload_path.to_str().expect("utf-8"));
3054
3055        let status = worker_artifact(
3056            State(state.clone()),
3057            bearer_headers(&handle),
3058            Query(ArtifactQuery {
3059                name: "scout".to_string(),
3060            }),
3061            axum::body::Bytes::from(body),
3062        )
3063        .await
3064        .expect("worker_artifact sentinel");
3065        assert_eq!(status, StatusCode::NO_CONTENT);
3066    }
3067
3068    /// GH #43 — sentinel with `work_dir` seeded but no
3069    /// `allow_file_submit` opt-in → `400` (default-deny). The file exists
3070    /// and sits under `work_dir`, so the rejection is attributable to the
3071    /// missing opt-in alone.
3072    #[tokio::test]
3073    async fn worker_submit_rejects_sentinel_without_allow_flag() {
3074        let data_store: Arc<dyn OutputStore> = Arc::new(InMemoryOutputStore::new());
3075        let run_store: Arc<dyn RunStore> = Arc::new(InMemoryRunStore::new());
3076        let state = test_state(data_store, run_store);
3077        let task_id = StepId::new();
3078        let handle = seed_task_with_handle(&state, &task_id, "planner", 1, None).await;
3079
3080        let tmp = tempfile::tempdir().expect("tempdir");
3081        seed_work_dir(
3082            &state,
3083            &task_id,
3084            1,
3085            tmp.path().to_str().expect("utf-8"),
3086            None,
3087        )
3088        .await;
3089
3090        let payload_path = tmp.path().join("out.md");
3091        tokio::fs::write(&payload_path, b"resolvable body")
3092            .await
3093            .expect("write payload");
3094        let body = format!("@file:{}", payload_path.to_str().expect("utf-8"));
3095
3096        let err = worker_submit(
3097            State(state.clone()),
3098            bearer_headers(&handle),
3099            Query(SubmitQuery {
3100                ok: None,
3101                verdict: None,
3102            }),
3103            axum::body::Bytes::from(body),
3104        )
3105        .await
3106        .expect_err("missing opt-in must reject sentinel");
3107        assert_eq!(err.status, StatusCode::BAD_REQUEST);
3108        assert!(
3109            err.message.contains("not allowed"),
3110            "rejection must name the opt-in guard, got: {}",
3111            err.message
3112        );
3113    }
3114
3115    /// GH #43 — the opt-in is the strict boolean `true`: `Bool(false)`
3116    /// and the string `"true"` are both rejected with `400`.
3117    #[tokio::test]
3118    async fn worker_submit_rejects_sentinel_with_non_true_allow_values() {
3119        for allow in [Value::Bool(false), Value::String("true".to_string())] {
3120            let data_store: Arc<dyn OutputStore> = Arc::new(InMemoryOutputStore::new());
3121            let run_store: Arc<dyn RunStore> = Arc::new(InMemoryRunStore::new());
3122            let state = test_state(data_store, run_store);
3123            let task_id = StepId::new();
3124            let handle = seed_task_with_handle(&state, &task_id, "planner", 1, None).await;
3125
3126            let tmp = tempfile::tempdir().expect("tempdir");
3127            seed_work_dir(
3128                &state,
3129                &task_id,
3130                1,
3131                tmp.path().to_str().expect("utf-8"),
3132                Some(allow.clone()),
3133            )
3134            .await;
3135
3136            let payload_path = tmp.path().join("out.md");
3137            tokio::fs::write(&payload_path, b"resolvable body")
3138                .await
3139                .expect("write payload");
3140            let body = format!("@file:{}", payload_path.to_str().expect("utf-8"));
3141
3142            let err = worker_submit(
3143                State(state.clone()),
3144                bearer_headers(&handle),
3145                Query(SubmitQuery {
3146                    ok: None,
3147                    verdict: None,
3148                }),
3149                axum::body::Bytes::from(body),
3150            )
3151            .await
3152            .expect_err("non-true opt-in value must reject sentinel");
3153            assert_eq!(err.status, StatusCode::BAD_REQUEST, "value: {allow:?}");
3154        }
3155    }
3156
3157    // ──────────────────────────────────────────────────────────────────
3158    // `submit_format` — the submit-route half of the contract. Default
3159    // (undeclared) STAGES `Value::String` exactly as before (the lenient
3160    // container parse is the engine fold's job, not this route's);
3161    // `"json"` parses here (any JSON value) and rejects `422` when the
3162    // body does not parse; `"text"` is a recognized no-op here whose
3163    // effect lives in the engine fold (`FoldParse::Raw`).
3164    // ──────────────────────────────────────────────────────────────────
3165
3166    /// Seeds an `agent_ctx` entry carrying the agent name plus, when
3167    /// `submit_format` is `Some`, that value under [`SUBMIT_FORMAT_KEY`]
3168    /// in `view.extra` — the shape `AgentContextMiddleware` folds from
3169    /// the Blueprint meta channels at spawn time. `work_dir`, when given,
3170    /// also enables the `@file:` sentinel (`allow_file_submit: true`), so
3171    /// one helper covers the sentinel + parse combination.
3172    async fn seed_submit_format(
3173        state: &AppState,
3174        task_id: &StepId,
3175        attempt: u32,
3176        agent: &str,
3177        submit_format: Option<Value>,
3178        work_dir: Option<&str>,
3179    ) {
3180        let tid = task_id.clone();
3181        let agent = agent.to_string();
3182        let work_dir = work_dir.map(|w| w.to_string());
3183        state
3184            .engine
3185            .with_state("test.seed_submit_format", move |s| {
3186                let mut entry = mlua_swarm::core::state::AgentCtxEntry::default();
3187                entry.view.agent = agent;
3188                if let Some(w) = work_dir {
3189                    entry.view.work_dir = Some(w);
3190                    entry
3191                        .view
3192                        .extra
3193                        .insert(FILE_SENTINEL_ALLOW_KEY.to_string(), Value::Bool(true));
3194                }
3195                if let Some(v) = submit_format {
3196                    entry.view.extra.insert(SUBMIT_FORMAT_KEY.to_string(), v);
3197                }
3198                s.agent_ctx.insert((tid, attempt), entry);
3199            })
3200            .await
3201            .expect("seed_submit_format");
3202    }
3203
3204    /// Reads back the `Final` event's value from the in-memory
3205    /// `output_store` tail `submit_worker_result_trusted` writes to.
3206    async fn final_value(state: &AppState, task_id: &StepId, attempt: u32) -> Option<Value> {
3207        let tid = task_id.clone();
3208        state
3209            .engine
3210            .with_state("test.inspect_output_store", move |s| {
3211                s.output_store.get(&(tid.clone(), attempt)).and_then(|evs| {
3212                    evs.iter().find_map(|ev| match ev {
3213                        OutputEvent::Final {
3214                            content: ContentRef::Inline { value },
3215                            ..
3216                        } => Some(value.clone()),
3217                        _ => None,
3218                    })
3219                })
3220            })
3221            .await
3222            .expect("with_state")
3223    }
3224
3225    /// Route-level regression lock: a step with a materialized view but
3226    /// NO `submit_format` declaration STAGES its body as a string even
3227    /// when that body happens to be valid JSON — this route never sniffs
3228    /// the payload. (The default lenient container parse happens later,
3229    /// at the engine's Final-pull fold — `FoldParse::Lenient`, tested in
3230    /// `mlua_swarm::core::engine` — which is exactly why the staged bytes
3231    /// here must stay raw: they are what `materialize_final_submission` /
3232    /// `materialize_part` and the verdict-contract checks see.)
3233    #[tokio::test]
3234    async fn worker_submit_without_submit_format_stages_json_looking_body_as_string() {
3235        let data_store: Arc<dyn OutputStore> = Arc::new(InMemoryOutputStore::new());
3236        let run_store: Arc<dyn RunStore> = Arc::new(InMemoryRunStore::new());
3237        let state = test_state(data_store, run_store);
3238        let task_id = StepId::new();
3239        let handle = seed_task_with_handle(&state, &task_id, "planner", 1, None).await;
3240        seed_submit_format(&state, &task_id, 1, "planner", None, None).await;
3241
3242        let body = r#"{"lanes":["a","b"]}"#;
3243        let status = worker_submit(
3244            State(state.clone()),
3245            bearer_headers(&handle),
3246            Query(SubmitQuery {
3247                ok: None,
3248                verdict: None,
3249            }),
3250            axum::body::Bytes::from(body),
3251        )
3252        .await
3253        .expect("undeclared submit must succeed");
3254        assert_eq!(status, StatusCode::NO_CONTENT);
3255
3256        assert_eq!(
3257            final_value(&state, &task_id, 1).await,
3258            Some(Value::String(body.to_string())),
3259            "an undeclared step must keep the raw-string fold",
3260        );
3261    }
3262
3263    /// `submit_format: "json"` + a parseable body → the folded value is
3264    /// the structured JSON, so a downstream path (`$.<step>.lanes`)
3265    /// resolves instead of hitting one opaque string.
3266    #[tokio::test]
3267    async fn worker_submit_declared_json_folds_structured_value() {
3268        let data_store: Arc<dyn OutputStore> = Arc::new(InMemoryOutputStore::new());
3269        let run_store: Arc<dyn RunStore> = Arc::new(InMemoryRunStore::new());
3270        let state = test_state(data_store, run_store);
3271        let task_id = StepId::new();
3272        let handle = seed_task_with_handle(&state, &task_id, "planner", 1, None).await;
3273        seed_submit_format(
3274            &state,
3275            &task_id,
3276            1,
3277            "planner",
3278            Some(Value::String(SUBMIT_FORMAT_JSON.to_string())),
3279            None,
3280        )
3281        .await;
3282
3283        let status = worker_submit(
3284            State(state.clone()),
3285            bearer_headers(&handle),
3286            Query(SubmitQuery {
3287                ok: None,
3288                verdict: None,
3289            }),
3290            axum::body::Bytes::from(r#"{"lanes":["auth","billing"],"verdict":"PASS"}"#),
3291        )
3292        .await
3293        .expect("declared JSON submit must succeed");
3294        assert_eq!(status, StatusCode::NO_CONTENT);
3295
3296        let value = final_value(&state, &task_id, 1)
3297            .await
3298            .expect("Final event present");
3299        assert_eq!(
3300            value,
3301            json!({"lanes": ["auth", "billing"], "verdict": "PASS"})
3302        );
3303        // The whole point of the opt-in: fields are addressable.
3304        assert_eq!(value["lanes"], json!(["auth", "billing"]));
3305        assert_eq!(value["verdict"], json!("PASS"));
3306    }
3307
3308    /// Declared-strict: a declared step whose body does not parse is
3309    /// rejected with `422` (naming the agent and echoing the body head),
3310    /// and nothing reaches the flow ctx.
3311    #[tokio::test]
3312    async fn worker_submit_declared_json_rejects_unparseable_body_with_422() {
3313        let data_store: Arc<dyn OutputStore> = Arc::new(InMemoryOutputStore::new());
3314        let run_store: Arc<dyn RunStore> = Arc::new(InMemoryRunStore::new());
3315        let state = test_state(data_store, run_store);
3316        let task_id = StepId::new();
3317        let handle = seed_task_with_handle(&state, &task_id, "planner", 1, None).await;
3318        seed_submit_format(
3319            &state,
3320            &task_id,
3321            1,
3322            "planner",
3323            Some(Value::String(SUBMIT_FORMAT_JSON.to_string())),
3324            None,
3325        )
3326        .await;
3327
3328        let err = worker_submit(
3329            State(state.clone()),
3330            bearer_headers(&handle),
3331            Query(SubmitQuery {
3332                ok: None,
3333                verdict: None,
3334            }),
3335            axum::body::Bytes::from("DONE — 3 lanes planned, see the report above"),
3336        )
3337        .await
3338        .expect_err("a declared step must not fold an unparseable body");
3339        assert_eq!(err.status, StatusCode::UNPROCESSABLE_ENTITY);
3340        assert!(
3341            err.message.contains("planner") && err.message.contains("DONE"),
3342            "the rejection must name the agent and echo the body head, got: {}",
3343            err.message
3344        );
3345        assert_eq!(
3346            final_value(&state, &task_id, 1).await,
3347            None,
3348            "a rejected submit must not reach the output tail",
3349        );
3350    }
3351
3352    /// The `@file:` sentinel and the parse compose: the file is resolved
3353    /// first, then its contents are parsed, so a large structured payload
3354    /// can take the file lane without losing its shape.
3355    #[tokio::test]
3356    async fn worker_submit_declared_json_parses_file_sentinel_contents() {
3357        let data_store: Arc<dyn OutputStore> = Arc::new(InMemoryOutputStore::new());
3358        let run_store: Arc<dyn RunStore> = Arc::new(InMemoryRunStore::new());
3359        let state = test_state(data_store, run_store);
3360        let task_id = StepId::new();
3361        let handle = seed_task_with_handle(&state, &task_id, "planner", 1, None).await;
3362
3363        let tmp = tempfile::tempdir().expect("tempdir");
3364        let work_dir = tmp.path().to_path_buf();
3365        seed_submit_format(
3366            &state,
3367            &task_id,
3368            1,
3369            "planner",
3370            Some(Value::String(SUBMIT_FORMAT_JSON.to_string())),
3371            Some(work_dir.to_str().expect("work_dir utf-8")),
3372        )
3373        .await;
3374
3375        let payload_path = work_dir.join("plan.json");
3376        tokio::fs::write(&payload_path, "{\"lanes\": [\"auth\", \"billing\"]}\n")
3377            .await
3378            .expect("write payload");
3379        let body = format!(
3380            "@file:{}",
3381            payload_path.to_str().expect("payload path utf-8")
3382        );
3383
3384        let status = worker_submit(
3385            State(state.clone()),
3386            bearer_headers(&handle),
3387            Query(SubmitQuery {
3388                ok: None,
3389                verdict: None,
3390            }),
3391            axum::body::Bytes::from(body),
3392        )
3393        .await
3394        .expect("sentinel + declared JSON submit must succeed");
3395        assert_eq!(status, StatusCode::NO_CONTENT);
3396
3397        assert_eq!(
3398            final_value(&state, &task_id, 1).await,
3399            Some(json!({"lanes": ["auth", "billing"]})),
3400        );
3401    }
3402
3403    /// An unrecognized declared value is not a client error: the body
3404    /// folds as a string (the default lane) and the server warns. Locks
3405    /// the fallback so a typo degrades visibly instead of 422-ing a
3406    /// worker that did nothing wrong.
3407    #[tokio::test]
3408    async fn worker_submit_unknown_submit_format_falls_back_to_string() {
3409        for declared in [
3410            Value::String("yaml".to_string()),
3411            Value::Bool(true),
3412            Value::Number(1.into()),
3413        ] {
3414            let data_store: Arc<dyn OutputStore> = Arc::new(InMemoryOutputStore::new());
3415            let run_store: Arc<dyn RunStore> = Arc::new(InMemoryRunStore::new());
3416            let state = test_state(data_store, run_store);
3417            let task_id = StepId::new();
3418            let handle = seed_task_with_handle(&state, &task_id, "planner", 1, None).await;
3419            seed_submit_format(&state, &task_id, 1, "planner", Some(declared.clone()), None).await;
3420
3421            let status = worker_submit(
3422                State(state.clone()),
3423                bearer_headers(&handle),
3424                Query(SubmitQuery {
3425                    ok: None,
3426                    verdict: None,
3427                }),
3428                axum::body::Bytes::from(r#"{"lanes":["a"]}"#),
3429            )
3430            .await
3431            .unwrap_or_else(|e| panic!("unknown value must not reject ({declared}): {e:?}"));
3432            assert_eq!(status, StatusCode::NO_CONTENT);
3433
3434            assert_eq!(
3435                final_value(&state, &task_id, 1).await,
3436                Some(Value::String(r#"{"lanes":["a"]}"#.to_string())),
3437                "unknown value {declared} must keep the string fold",
3438            );
3439        }
3440    }
3441
3442    /// `submit_format: "text"` is a recognized value at this route: the
3443    /// body stages as a string (like undeclared), succeeds, and is NOT
3444    /// the unknown-value warn path. Its real effect — opting the step's
3445    /// fold out of the lenient container parse — is the engine fold's
3446    /// job (`FoldParse::Raw`, tested in `mlua_swarm::core::engine`).
3447    #[tokio::test]
3448    async fn worker_submit_declared_text_stages_string() {
3449        let data_store: Arc<dyn OutputStore> = Arc::new(InMemoryOutputStore::new());
3450        let run_store: Arc<dyn RunStore> = Arc::new(InMemoryRunStore::new());
3451        let state = test_state(data_store, run_store);
3452        let task_id = StepId::new();
3453        let handle = seed_task_with_handle(&state, &task_id, "planner", 1, None).await;
3454        seed_submit_format(
3455            &state,
3456            &task_id,
3457            1,
3458            "planner",
3459            Some(Value::String("text".to_string())),
3460            None,
3461        )
3462        .await;
3463
3464        let body = r#"{"lanes":["a","b"]}"#;
3465        let status = worker_submit(
3466            State(state.clone()),
3467            bearer_headers(&handle),
3468            Query(SubmitQuery {
3469                ok: None,
3470                verdict: None,
3471            }),
3472            axum::body::Bytes::from(body),
3473        )
3474        .await
3475        .expect("text-declared submit must succeed");
3476        assert_eq!(status, StatusCode::NO_CONTENT);
3477
3478        assert_eq!(
3479            final_value(&state, &task_id, 1).await,
3480            Some(Value::String(body.to_string())),
3481            "a text-declared step must stage the raw string",
3482        );
3483    }
3484
3485    /// Order lock: the verdict contract still sees the pre-parse string
3486    /// for an undeclared gate agent — a `channel: "body"` contract keeps
3487    /// accepting its bare token and keeps rejecting a non-member value,
3488    /// byte-for-byte as before the opt-in existed.
3489    #[tokio::test]
3490    async fn worker_submit_verdict_contract_unchanged_without_submit_format() {
3491        let data_store: Arc<dyn OutputStore> = Arc::new(InMemoryOutputStore::new());
3492        let run_store: Arc<dyn RunStore> = Arc::new(InMemoryRunStore::new());
3493        let state = test_state(data_store, run_store);
3494        state.engine.register_verdict_contracts(HashMap::from([(
3495            "gate".to_string(),
3496            body_verdict_contract(&["PASS", "BLOCKED"]),
3497        )]));
3498
3499        // Member value: accepted, folded as the same bare string.
3500        let accepted = StepId::new();
3501        let handle = seed_task_with_handle(&state, &accepted, "gate", 1, None).await;
3502        seed_submit_format(&state, &accepted, 1, "gate", None, None).await;
3503        let status = worker_submit(
3504            State(state.clone()),
3505            bearer_headers(&handle),
3506            Query(SubmitQuery {
3507                ok: None,
3508                verdict: None,
3509            }),
3510            axum::body::Bytes::from("BLOCKED"),
3511        )
3512        .await
3513        .expect("a declared verdict value must still pass");
3514        assert_eq!(status, StatusCode::NO_CONTENT);
3515        assert_eq!(
3516            final_value(&state, &accepted, 1).await,
3517            Some(Value::String("BLOCKED".to_string())),
3518        );
3519
3520        // Non-member value: still the pre-existing 422 from the contract,
3521        // not a submit_format error.
3522        let rejected = StepId::new();
3523        let handle = seed_task_with_handle(&state, &rejected, "gate", 1, None).await;
3524        seed_submit_format(&state, &rejected, 1, "gate", None, None).await;
3525        let err = worker_submit(
3526            State(state.clone()),
3527            bearer_headers(&handle),
3528            Query(SubmitQuery {
3529                ok: None,
3530                verdict: None,
3531            }),
3532            axum::body::Bytes::from("UNKNOWN"),
3533        )
3534        .await
3535        .expect_err("a non-member verdict value must still be rejected");
3536        assert_eq!(err.status, StatusCode::UNPROCESSABLE_ENTITY);
3537        assert!(
3538            err.message.contains("verdict contract violation"),
3539            "the verdict contract must own this rejection, got: {}",
3540            err.message
3541        );
3542    }
3543
3544    /// End-to-end of the motivating shape: a planner declares
3545    /// `submit_format: "json"`, submits `{"lanes": [...]}`, and a `fanout`
3546    /// whose `items` is `$.<step>.lanes` dispatches one lane per element.
3547    /// Before the opt-in the same submit folded as a string and the
3548    /// `items` path could not be resolved at all.
3549    #[tokio::test]
3550    async fn declared_json_submit_feeds_a_fanout_items_path() {
3551        use mlua_flow_ir::{EvalError, Expr, JoinMode, Node as FlowNode};
3552
3553        let data_store: Arc<dyn OutputStore> = Arc::new(InMemoryOutputStore::new());
3554        let run_store: Arc<dyn RunStore> = Arc::new(InMemoryRunStore::new());
3555        let state = test_state(data_store, run_store);
3556        let task_id = StepId::new();
3557        let handle = seed_task_with_handle(&state, &task_id, "planner", 1, None).await;
3558        seed_submit_format(
3559            &state,
3560            &task_id,
3561            1,
3562            "planner",
3563            Some(Value::String(SUBMIT_FORMAT_JSON.to_string())),
3564            None,
3565        )
3566        .await;
3567
3568        worker_submit(
3569            State(state.clone()),
3570            bearer_headers(&handle),
3571            Query(SubmitQuery {
3572                ok: None,
3573                verdict: None,
3574            }),
3575            axum::body::Bytes::from(r#"{"lanes":["auth","billing","search"]}"#),
3576        )
3577        .await
3578        .expect("declared JSON submit must succeed");
3579        let planner_out = final_value(&state, &task_id, 1)
3580            .await
3581            .expect("Final event present");
3582
3583        // The step's OUTPUT as the BP chain would see it, under `$.planner`.
3584        let path = |s: &str| Expr::Path {
3585            at: s.parse().expect("literal test path"),
3586        };
3587        let flow = FlowNode::Fanout {
3588            items: path("$.planner.lanes"),
3589            bind: path("$.item"),
3590            body: Box::new(FlowNode::Step {
3591                ref_: "check".to_string(),
3592                in_: path("$.item"),
3593                out: path("$.branch_out"),
3594            }),
3595            join: JoinMode::All,
3596            out: path("$.results"),
3597        };
3598        let dispatcher = |_ref: &str, input: Value| -> Result<Value, EvalError> { Ok(input) };
3599        let final_ctx = mlua_flow_ir::eval(&flow, json!({ "planner": planner_out }), &dispatcher)
3600            .expect("fanout over the parsed submit must evaluate");
3601
3602        let lanes: Vec<&Value> = final_ctx["results"]
3603            .as_array()
3604            .expect("results is an array")
3605            .iter()
3606            .map(|lane_ctx| &lane_ctx["branch_out"])
3607            .collect();
3608        assert_eq!(
3609            lanes,
3610            vec![&json!("auth"), &json!("billing"), &json!("search")]
3611        );
3612    }
3613
3614    // ──────────────────────────────────────────────────────────────────
3615    // GH #50 (Subtask 2) — submit-time verdict contract gate, handler-
3616    // level unit coverage. The full process-boundary HTTP round trip
3617    // (Acceptance Criterion #7) lives in
3618    // `crates/mlua-swarm-server/tests/verdict_contract.rs`; these are the
3619    // fast in-process counterpart exercising `worker_submit` /
3620    // `worker_artifact` directly, same convention as the sentinel tests
3621    // above.
3622    // ──────────────────────────────────────────────────────────────────
3623
3624    fn body_verdict_contract(values: &[&str]) -> mlua_swarm_schema::VerdictContract {
3625        mlua_swarm_schema::VerdictContract {
3626            channel: VerdictChannel::Body,
3627            values: values.iter().map(|v| v.to_string()).collect(),
3628        }
3629    }
3630
3631    fn part_verdict_contract(values: &[&str]) -> mlua_swarm_schema::VerdictContract {
3632        mlua_swarm_schema::VerdictContract {
3633            channel: VerdictChannel::Part,
3634            values: values.iter().map(|v| v.to_string()).collect(),
3635        }
3636    }
3637
3638    /// A `channel: "body"` contract rejects a `worker_submit` body outside
3639    /// its declared `values` with `422`, echoing the expected token set.
3640    #[tokio::test]
3641    async fn worker_submit_rejects_body_outside_contract_values_with_422() {
3642        let data_store: Arc<dyn OutputStore> = Arc::new(InMemoryOutputStore::new());
3643        let run_store: Arc<dyn RunStore> = Arc::new(InMemoryRunStore::new());
3644        let state = test_state(data_store, run_store);
3645        let task_id = StepId::new();
3646        let handle = seed_task_with_handle(&state, &task_id, "gate", 1, None).await;
3647        state.engine.register_verdict_contracts(HashMap::from([(
3648            "gate".to_string(),
3649            body_verdict_contract(&["PASS", "BLOCKED"]),
3650        )]));
3651
3652        let err = worker_submit(
3653            State(state.clone()),
3654            bearer_headers(&handle),
3655            Query(SubmitQuery {
3656                ok: None,
3657                verdict: None,
3658            }),
3659            axum::body::Bytes::from("UNKNOWN"),
3660        )
3661        .await
3662        .expect_err("value outside declared values must reject");
3663        assert_eq!(err.status, StatusCode::UNPROCESSABLE_ENTITY);
3664        assert!(
3665            err.message.contains("PASS") && err.message.contains("BLOCKED"),
3666            "rejection must echo the declared values, got: {}",
3667            err.message
3668        );
3669    }
3670
3671    /// The same contract accepts a body that IS a member of `values` —
3672    /// `204`, unaffected submit.
3673    #[tokio::test]
3674    async fn worker_submit_accepts_body_inside_contract_values() {
3675        let data_store: Arc<dyn OutputStore> = Arc::new(InMemoryOutputStore::new());
3676        let run_store: Arc<dyn RunStore> = Arc::new(InMemoryRunStore::new());
3677        let state = test_state(data_store, run_store);
3678        let task_id = StepId::new();
3679        let handle = seed_task_with_handle(&state, &task_id, "gate", 1, None).await;
3680        state.engine.register_verdict_contracts(HashMap::from([(
3681            "gate".to_string(),
3682            body_verdict_contract(&["PASS", "BLOCKED"]),
3683        )]));
3684
3685        let status = worker_submit(
3686            State(state.clone()),
3687            bearer_headers(&handle),
3688            Query(SubmitQuery {
3689                ok: None,
3690                verdict: None,
3691            }),
3692            axum::body::Bytes::from("PASS"),
3693        )
3694        .await
3695        .expect("value inside declared values must succeed");
3696        assert_eq!(status, StatusCode::NO_CONTENT);
3697    }
3698
3699    /// Opt-in regression guard: an agent with NO declared verdict contract
3700    /// is entirely unaffected — `worker_submit` still returns `204` for an
3701    /// arbitrary body, exactly the pre-GH-#50 behavior.
3702    #[tokio::test]
3703    async fn worker_submit_without_a_declared_contract_is_unaffected() {
3704        let data_store: Arc<dyn OutputStore> = Arc::new(InMemoryOutputStore::new());
3705        let run_store: Arc<dyn RunStore> = Arc::new(InMemoryRunStore::new());
3706        let state = test_state(data_store, run_store);
3707        let task_id = StepId::new();
3708        // No `register_verdict_contracts` call — the agent declared no contract.
3709        let handle = seed_task_with_handle(&state, &task_id, "undeclared-agent", 1, None).await;
3710
3711        let status = worker_submit(
3712            State(state.clone()),
3713            bearer_headers(&handle),
3714            Query(SubmitQuery {
3715                ok: None,
3716                verdict: None,
3717            }),
3718            axum::body::Bytes::from("anything at all, no contract to violate"),
3719        )
3720        .await
3721        .expect("no contract declared must never reject");
3722        assert_eq!(status, StatusCode::NO_CONTENT);
3723    }
3724
3725    /// A `channel: "part"` contract rejects a `worker_artifact?name=verdict`
3726    /// value outside `values` with `422`.
3727    #[tokio::test]
3728    async fn worker_artifact_verdict_part_rejects_value_outside_contract_with_422() {
3729        let data_store: Arc<dyn OutputStore> = Arc::new(InMemoryOutputStore::new());
3730        let run_store: Arc<dyn RunStore> = Arc::new(InMemoryRunStore::new());
3731        let state = test_state(data_store, run_store);
3732        let task_id = StepId::new();
3733        let handle = seed_task_with_handle(&state, &task_id, "gate", 1, None).await;
3734        state.engine.register_verdict_contracts(HashMap::from([(
3735            "gate".to_string(),
3736            part_verdict_contract(&["PASS", "BLOCKED"]),
3737        )]));
3738
3739        let err = worker_artifact(
3740            State(state.clone()),
3741            bearer_headers(&handle),
3742            Query(ArtifactQuery {
3743                name: "verdict".to_string(),
3744            }),
3745            axum::body::Bytes::from("UNKNOWN"),
3746        )
3747        .await
3748        .expect_err("value outside declared values must reject");
3749        assert_eq!(err.status, StatusCode::UNPROCESSABLE_ENTITY);
3750    }
3751
3752    /// A part named anything OTHER than `"verdict"` skips the gate
3753    /// entirely, even with a `channel: "part"` contract declared — `204`,
3754    /// existing pre-GH-#50 behavior unchanged.
3755    #[tokio::test]
3756    async fn worker_artifact_non_verdict_part_skips_the_gate() {
3757        let data_store: Arc<dyn OutputStore> = Arc::new(InMemoryOutputStore::new());
3758        let run_store: Arc<dyn RunStore> = Arc::new(InMemoryRunStore::new());
3759        let state = test_state(data_store, run_store);
3760        let task_id = StepId::new();
3761        let handle = seed_task_with_handle(&state, &task_id, "gate", 1, None).await;
3762        state.engine.register_verdict_contracts(HashMap::from([(
3763            "gate".to_string(),
3764            part_verdict_contract(&["PASS", "BLOCKED"]),
3765        )]));
3766
3767        let status = worker_artifact(
3768            State(state.clone()),
3769            bearer_headers(&handle),
3770            Query(ArtifactQuery {
3771                name: "notes".to_string(),
3772            }),
3773            axum::body::Bytes::from("anything at all"),
3774        )
3775        .await
3776        .expect("non-verdict part name must never be gated");
3777        assert_eq!(status, StatusCode::NO_CONTENT);
3778    }
3779}