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                lints: None,
2009            }],
2010            operators: vec![],
2011            metas: vec![],
2012            hints: CompilerHints::default(),
2013            strategy: CompilerStrategy::default(),
2014            metadata: BlueprintMetadata::default(),
2015            spawner_hints: Default::default(),
2016            default_agent_kind: AgentKind::Operator,
2017            default_operator_kind: None,
2018            default_init_ctx: None,
2019            default_agent_ctx: None,
2020            default_context_policy: None,
2021            projection_placement: None,
2022            audits: vec![],
2023            degradation_policy: None,
2024            runners: vec![],
2025            default_runner: None,
2026            subprocesses: vec![],
2027            check_policy: None,
2028            blueprint_ref_includes: Vec::new(),
2029        }
2030    }
2031
2032    /// Test 8 (GH #23 subtask-3, declared-name E2E — Worker axis half): a
2033    /// declared `projection_name` makes `StepPointer.name` the CANONICAL
2034    /// name (not the raw `Step.ref` the Data-plane / `step_entries` still
2035    /// index by), and `ContextPolicy.steps` naming the canonical name
2036    /// matches it.
2037    #[tokio::test]
2038    async fn declared_projection_name_pointer_name_is_canonical_and_policy_matches_it() {
2039        let data_store: Arc<dyn OutputStore> = Arc::new(InMemoryOutputStore::new());
2040        let run_store: Arc<dyn RunStore> = Arc::new(InMemoryRunStore::new());
2041        let task_id = TaskId::new();
2042        let run_id = RunId::new();
2043        let planner_id = StepId::new();
2044
2045        // The Data-plane store is keyed by the CANONICAL name — GH #23
2046        // subtask-2's sink already writes it that way.
2047        append_final(
2048            &data_store,
2049            planner_id.as_str(),
2050            "plan-out",
2051            json!({"plan": "x"}),
2052        )
2053        .await;
2054        run_store
2055            .create(run_record(
2056                &task_id,
2057                &run_id,
2058                vec![step_entry(&planner_id, "planner")],
2059            ))
2060            .await
2061            .expect("create run");
2062
2063        let state = test_state(data_store, run_store);
2064
2065        // Seed the `StepNaming` table the way `Compiler::compile` +
2066        // `EngineDispatcher::dispatch` would have — the same `Arc` stashed
2067        // under every dispatched step's own id, including the FETCHING
2068        // agent's (`consumer_id`), which `assemble_step_pointers` looks up
2069        // via `Engine::step_naming_for(&payload.task_id)`.
2070        let (naming, _warnings) =
2071            mlua_swarm::core::step_naming::StepNaming::from_blueprint(&declared_name_bp())
2072                .expect("no collision");
2073        let naming = Arc::new(naming);
2074        let consumer_id = StepId::new();
2075        state
2076            .engine
2077            .with_state("test.seed_step_naming", {
2078                let naming = naming.clone();
2079                let planner_id = planner_id.clone();
2080                let consumer_id = consumer_id.clone();
2081                move |s| {
2082                    s.step_namings.insert(planner_id, naming.clone());
2083                    s.step_namings.insert(consumer_id, naming);
2084                }
2085            })
2086            .await
2087            .expect("seed step naming");
2088        state
2089            .engine
2090            .with_state("test.seed_policy", {
2091                let consumer_id = consumer_id.clone();
2092                move |s| {
2093                    s.agent_ctx.insert(
2094                        (consumer_id, 1),
2095                        mlua_swarm::core::state::AgentCtxEntry {
2096                            policy: mlua_swarm_schema::ContextPolicy {
2097                                steps: Some(vec!["plan-out".to_string()]),
2098                                ..Default::default()
2099                            },
2100                            ..Default::default()
2101                        },
2102                    );
2103                }
2104            })
2105            .await
2106            .expect("seed policy");
2107
2108        let mut payload = consumer_payload(&consumer_id, &run_id);
2109        assemble_step_pointers(&state, &mut payload).await;
2110
2111        let steps = &payload.context.expect("context").steps;
2112        assert_eq!(steps.len(), 1, "steps: {steps:?}");
2113        assert_eq!(
2114            steps[0].name, "plan-out",
2115            "StepPointer.name must be the canonical name"
2116        );
2117    }
2118
2119    // ──────────────────────────────────────────────────────────────────────
2120    // GH #31 — `/v1/worker/prompt/system` + `/v1/agents/:name/render-size`
2121    // ──────────────────────────────────────────────────────────────────────
2122
2123    /// Seeds a task + baked system prompt + a short worker handle bound to
2124    /// it, mirroring the shape `Engine::dispatch_attempt` would have
2125    /// produced (minus the parts these two routes don't touch: no real
2126    /// HMAC-signed `CapToken`, since `task_id_from_handle`'s handle → fp →
2127    /// task_id chain is what's under test, not signature verification).
2128    async fn seed_task_with_handle(
2129        state: &AppState,
2130        task_id: &StepId,
2131        agent: &str,
2132        attempt: u32,
2133        system: Option<String>,
2134    ) -> String {
2135        let handle = format!("wh-{}", mlua_swarm::types::secure_hex(4));
2136        let task_id = task_id.clone();
2137        let agent = agent.to_string();
2138        let handle_clone = handle.clone();
2139        state
2140            .engine
2141            .with_state("test.seed_task_with_handle", move |s| {
2142                let mut task = mlua_swarm::core::state::TaskState::new(
2143                    task_id.clone(),
2144                    mlua_swarm::core::state::TaskSpec {
2145                        agent: agent.clone(),
2146                        initial_directive: json!("x"),
2147                        step_ctx: None,
2148                        check_policy: None,
2149                    },
2150                );
2151                task.attempt = attempt;
2152                s.tasks.insert(task_id.clone(), task);
2153                s.systems.insert((task_id.clone(), attempt), system);
2154                let token = CapToken {
2155                    agent_id: agent,
2156                    role: mlua_swarm::Role::Worker,
2157                    scopes: vec!["*".to_string()],
2158                    issued_at: 0,
2159                    expire_at: u64::MAX,
2160                    max_uses: None,
2161                    nonce: format!("test-nonce-{task_id}"),
2162                    sig_hex: String::new(),
2163                };
2164                let fp = token.fingerprint();
2165                s.tokens.insert(
2166                    fp.clone(),
2167                    mlua_swarm::core::state::CapTokenRecord {
2168                        token,
2169                        uses_left: None,
2170                        revoked: false,
2171                        task_id: Some(task_id),
2172                    },
2173                );
2174                s.worker_handles.insert(handle_clone, fp);
2175            })
2176            .await
2177            .expect("seed_task_with_handle");
2178        handle
2179    }
2180
2181    fn bearer_headers(handle: &str) -> HeaderMap {
2182        let mut headers = HeaderMap::new();
2183        headers.insert(
2184            AUTHORIZATION,
2185            format!("Bearer {handle}").parse().expect("header value"),
2186        );
2187        headers
2188    }
2189
2190    /// `GET /v1/worker/prompt/system` returns the exact raw baked bytes
2191    /// (not JSON-wrapped) with `Content-Type: text/plain`, for the
2192    /// `(task_id, attempt)` the handle is bound to.
2193    #[tokio::test]
2194    async fn worker_prompt_system_returns_raw_bytes_for_baked_system() {
2195        let data_store: Arc<dyn OutputStore> = Arc::new(InMemoryOutputStore::new());
2196        let run_store: Arc<dyn RunStore> = Arc::new(InMemoryRunStore::new());
2197        let state = test_state(data_store, run_store);
2198        let task_id = StepId::new();
2199        let rendered = "# Hello\n\nThis is the baked system prompt.".to_string();
2200        let handle =
2201            seed_task_with_handle(&state, &task_id, "planner", 1, Some(rendered.clone())).await;
2202
2203        let resp = worker_prompt_system(
2204            State(state.clone()),
2205            bearer_headers(&handle),
2206            Query(PromptSystemQuery {
2207                task_id: task_id.clone(),
2208                attempt: 1,
2209            }),
2210        )
2211        .await
2212        .expect("worker_prompt_system")
2213        .into_response();
2214
2215        assert_eq!(resp.status(), StatusCode::OK);
2216        let content_type = resp
2217            .headers()
2218            .get(header::CONTENT_TYPE)
2219            .expect("content-type header")
2220            .to_str()
2221            .expect("ascii");
2222        assert_eq!(content_type, "text/plain; charset=utf-8");
2223        let body_bytes = axum::body::to_bytes(resp.into_body(), usize::MAX)
2224            .await
2225            .expect("body bytes");
2226        assert_eq!(body_bytes.as_ref(), rendered.as_bytes());
2227    }
2228
2229    /// No baked system for the given `(task_id, attempt)` → 404, not a
2230    /// panic or a 200-with-empty-body.
2231    #[tokio::test]
2232    async fn worker_prompt_system_404s_when_no_baked_system() {
2233        let data_store: Arc<dyn OutputStore> = Arc::new(InMemoryOutputStore::new());
2234        let run_store: Arc<dyn RunStore> = Arc::new(InMemoryRunStore::new());
2235        let state = test_state(data_store, run_store);
2236        let task_id = StepId::new();
2237        let handle = seed_task_with_handle(&state, &task_id, "planner", 1, None).await;
2238
2239        let result = worker_prompt_system(
2240            State(state.clone()),
2241            bearer_headers(&handle),
2242            Query(PromptSystemQuery {
2243                task_id: task_id.clone(),
2244                attempt: 1,
2245            }),
2246        )
2247        .await;
2248        let err = match result {
2249            Ok(_) => panic!("expected 404 ApiError, got Ok"),
2250            Err(e) => e,
2251        };
2252        assert_eq!(err.into_response().status(), StatusCode::NOT_FOUND);
2253    }
2254
2255    /// A handle bound to a different task than the one requested must be
2256    /// rejected (400) — this is the same cross-check `worker_prompt` does.
2257    #[tokio::test]
2258    async fn worker_prompt_system_rejects_handle_task_mismatch() {
2259        let data_store: Arc<dyn OutputStore> = Arc::new(InMemoryOutputStore::new());
2260        let run_store: Arc<dyn RunStore> = Arc::new(InMemoryRunStore::new());
2261        let state = test_state(data_store, run_store);
2262        let task_id = StepId::new();
2263        let other_task_id = StepId::new();
2264        let handle =
2265            seed_task_with_handle(&state, &task_id, "planner", 1, Some("x".to_string())).await;
2266
2267        let result = worker_prompt_system(
2268            State(state.clone()),
2269            bearer_headers(&handle),
2270            Query(PromptSystemQuery {
2271                task_id: other_task_id,
2272                attempt: 1,
2273            }),
2274        )
2275        .await;
2276        let err = match result {
2277            Ok(_) => panic!("expected 400 ApiError for task mismatch, got Ok"),
2278            Err(e) => e,
2279        };
2280        assert_eq!(err.into_response().status(), StatusCode::BAD_REQUEST);
2281    }
2282
2283    /// `GET /v1/agents/:name/render-size` requires no auth, and reports
2284    /// `last_rendered_bytes: null` for an agent that has never had a
2285    /// `system_prompt` baked — a normal 200, not a 404.
2286    #[tokio::test]
2287    async fn agent_render_size_returns_null_for_unknown_agent() {
2288        let data_store: Arc<dyn OutputStore> = Arc::new(InMemoryOutputStore::new());
2289        let run_store: Arc<dyn RunStore> = Arc::new(InMemoryRunStore::new());
2290        let state = test_state(data_store, run_store);
2291
2292        let Json(body) = agent_render_size(
2293            State(state.clone()),
2294            axum::extract::Path("never-dispatched".to_string()),
2295        )
2296        .await;
2297        assert_eq!(body.agent, "never-dispatched");
2298        assert_eq!(body.last_rendered_bytes, None);
2299    }
2300
2301    /// Once `bake_worker_system_prompt` has recorded a render size for an
2302    /// agent, the route reports the most-recently-observed value.
2303    #[tokio::test]
2304    async fn agent_render_size_reports_last_rendered_bytes() {
2305        let data_store: Arc<dyn OutputStore> = Arc::new(InMemoryOutputStore::new());
2306        let run_store: Arc<dyn RunStore> = Arc::new(InMemoryRunStore::new());
2307        let state = test_state(data_store, run_store);
2308        let task_id = StepId::new();
2309        state
2310            .engine
2311            .with_state("test.seed_agent_ctx_for_bake", {
2312                let task_id = task_id.clone();
2313                move |s| {
2314                    s.tasks.insert(
2315                        task_id.clone(),
2316                        mlua_swarm::core::state::TaskState::new(
2317                            task_id,
2318                            mlua_swarm::core::state::TaskSpec {
2319                                agent: "coder".to_string(),
2320                                initial_directive: json!("x"),
2321                                step_ctx: None,
2322                                check_policy: None,
2323                            },
2324                        ),
2325                    );
2326                }
2327            })
2328            .await
2329            .expect("seed task");
2330        state
2331            .engine
2332            .bake_worker_system_prompt(&task_id, 1, Some("z".repeat(42)))
2333            .await
2334            .expect("bake_worker_system_prompt");
2335
2336        let Json(body) = agent_render_size(
2337            State(state.clone()),
2338            axum::extract::Path("coder".to_string()),
2339        )
2340        .await;
2341        assert_eq!(body.agent, "coder");
2342        assert_eq!(body.last_rendered_bytes, Some(42));
2343    }
2344
2345    // ──────────────────────────────────────────────────────────────────────
2346    // GH #36 ST1 — `POST /v1/worker/artifact`
2347    // ──────────────────────────────────────────────────────────────────────
2348
2349    /// A valid `?name=` + short-handle Bearer stages the raw body (trailing
2350    /// whitespace trimmed, same as `worker_submit`) as an `Artifact` on the
2351    /// task's current-attempt tail, and returns `204 No Content`.
2352    #[tokio::test]
2353    async fn worker_artifact_stages_and_204s_for_valid_request() {
2354        let data_store: Arc<dyn OutputStore> = Arc::new(InMemoryOutputStore::new());
2355        let run_store: Arc<dyn RunStore> = Arc::new(InMemoryRunStore::new());
2356        let state = test_state(data_store, run_store);
2357        let task_id = StepId::new();
2358        let handle = seed_task_with_handle(&state, &task_id, "planner", 1, None).await;
2359
2360        let status = worker_artifact(
2361            State(state.clone()),
2362            bearer_headers(&handle),
2363            Query(ArtifactQuery {
2364                name: "summary".to_string(),
2365            }),
2366            axum::body::Bytes::from_static(b"hello artifact\n"),
2367        )
2368        .await
2369        .expect("worker_artifact");
2370        assert_eq!(status, StatusCode::NO_CONTENT);
2371
2372        let tail = state.engine.output_tail(&task_id, 1).await;
2373        assert_eq!(tail.len(), 1, "tail: {tail:?}");
2374        match &tail[0] {
2375            OutputEvent::Artifact { name, content } => {
2376                assert_eq!(name, "summary");
2377                match content {
2378                    ContentRef::Inline { value } => {
2379                        assert_eq!(value, &json!("hello artifact"));
2380                    }
2381                    other => panic!("expected Inline content, got {other:?}"),
2382                }
2383            }
2384            other => panic!("expected Artifact event, got {other:?}"),
2385        }
2386    }
2387
2388    /// `?name=` missing entirely → axum's `Query` extractor rejection
2389    /// (400), not a panic. `Query<ArtifactQuery>` is constructed directly
2390    /// in this test (mirroring the other handlers' unit style, which call
2391    /// the handler fn with an already-extracted `Query`) — an empty `name`
2392    /// is exercised separately below since that case is NOT caught by the
2393    /// extractor and must be checked in the handler body.
2394    #[tokio::test]
2395    async fn worker_artifact_rejects_blank_name() {
2396        let data_store: Arc<dyn OutputStore> = Arc::new(InMemoryOutputStore::new());
2397        let run_store: Arc<dyn RunStore> = Arc::new(InMemoryRunStore::new());
2398        let state = test_state(data_store, run_store);
2399        let task_id = StepId::new();
2400        let handle = seed_task_with_handle(&state, &task_id, "planner", 1, None).await;
2401
2402        let result = worker_artifact(
2403            State(state.clone()),
2404            bearer_headers(&handle),
2405            Query(ArtifactQuery {
2406                name: "   ".to_string(),
2407            }),
2408            axum::body::Bytes::from_static(b"x"),
2409        )
2410        .await;
2411        let err = match result {
2412            Ok(_) => panic!("expected 400 ApiError for blank name, got Ok"),
2413            Err(e) => e,
2414        };
2415        assert_eq!(err.into_response().status(), StatusCode::BAD_REQUEST);
2416
2417        // Nothing was staged.
2418        assert!(state.engine.output_tail(&task_id, 1).await.is_empty());
2419    }
2420
2421    /// Staging the same `name` twice within one attempt is last-write-wins
2422    /// on the folded value (`fold_final_and_parts` in `mlua_swarm::core::
2423    /// engine`) — this test only asserts the raw tail carries both events
2424    /// in order (the fold itself is covered by that crate's own unit
2425    /// tests); `Engine::stage_worker_artifact_trusted`'s doc.
2426    #[tokio::test]
2427    async fn worker_artifact_staging_same_name_twice_appends_both_events_in_order() {
2428        let data_store: Arc<dyn OutputStore> = Arc::new(InMemoryOutputStore::new());
2429        let run_store: Arc<dyn RunStore> = Arc::new(InMemoryRunStore::new());
2430        let state = test_state(data_store, run_store);
2431        let task_id = StepId::new();
2432        let handle = seed_task_with_handle(&state, &task_id, "planner", 1, None).await;
2433
2434        for body in [b"first".as_slice(), b"second".as_slice()] {
2435            worker_artifact(
2436                State(state.clone()),
2437                bearer_headers(&handle),
2438                Query(ArtifactQuery {
2439                    name: "a".to_string(),
2440                }),
2441                axum::body::Bytes::copy_from_slice(body),
2442            )
2443            .await
2444            .expect("worker_artifact");
2445        }
2446
2447        let tail = state.engine.output_tail(&task_id, 1).await;
2448        assert_eq!(tail.len(), 2, "tail: {tail:?}");
2449        let values: Vec<&str> = tail
2450            .iter()
2451            .map(|ev| match ev {
2452                OutputEvent::Artifact {
2453                    content: ContentRef::Inline { value },
2454                    ..
2455                } => value.as_str().expect("string value"),
2456                other => panic!("expected Artifact/Inline event, got {other:?}"),
2457            })
2458            .collect();
2459        assert_eq!(values, vec!["first", "second"]);
2460    }
2461
2462    // ──────────────────────────────────────────────────────────────────
2463    // GH #37 — terminal-run guard (`reject_if_run_terminal`)
2464    // ──────────────────────────────────────────────────────────────────
2465
2466    /// Links a seeded dispatch task to a Run the same way
2467    /// `AgentContextMiddleware` does at spawn time: an `agent_ctx` entry
2468    /// whose view carries the `run_id`.
2469    async fn link_task_to_run(state: &AppState, task_id: &StepId, attempt: u32, run_id: &RunId) {
2470        let tid = task_id.clone();
2471        let rid_str = run_id.to_string();
2472        state
2473            .engine
2474            .with_state("test.link_task_to_run", move |s| {
2475                let mut entry = mlua_swarm::core::state::AgentCtxEntry::default();
2476                entry.view.run_id = Some(rid_str);
2477                s.agent_ctx.insert((tid, attempt), entry);
2478            })
2479            .await
2480            .expect("link_task_to_run");
2481    }
2482
2483    /// GH #37: a submit / artifact addressed at a Run that already
2484    /// reached a terminal status must be rejected with `410 Gone` — the
2485    /// flow-eval driver for that Run is gone, so a silent `204` here
2486    /// would orphan the worker's output.
2487    #[tokio::test]
2488    async fn submit_and_artifact_against_terminal_run_return_410() {
2489        let data_store: Arc<dyn OutputStore> = Arc::new(InMemoryOutputStore::new());
2490        let run_store: Arc<dyn RunStore> = Arc::new(InMemoryRunStore::new());
2491        let state = test_state(data_store, run_store.clone());
2492        let task_id = StepId::new();
2493        let handle = seed_task_with_handle(&state, &task_id, "planner", 1, None).await;
2494
2495        let owner_task = TaskId::new();
2496        let run_id = RunId::new();
2497        let mut rec = run_record(&owner_task, &run_id, vec![]);
2498        rec.status = RunStatus::Failed;
2499        run_store.create(rec).await.expect("run create");
2500        link_task_to_run(&state, &task_id, 1, &run_id).await;
2501
2502        let err = worker_submit(
2503            State(state.clone()),
2504            bearer_headers(&handle),
2505            Query(SubmitQuery {
2506                ok: None,
2507                verdict: None,
2508            }),
2509            axum::body::Bytes::from_static(b"LATE OUTPUT"),
2510        )
2511        .await
2512        .expect_err("a submit against a Failed run must be rejected");
2513        assert_eq!(err.status, StatusCode::GONE);
2514        assert!(
2515            err.message.contains(&run_id.to_string()),
2516            "the 410 must name the terminal run: {}",
2517            err.message
2518        );
2519
2520        let err = worker_artifact(
2521            State(state.clone()),
2522            bearer_headers(&handle),
2523            Query(ArtifactQuery {
2524                name: "part.md".to_string(),
2525            }),
2526            axum::body::Bytes::from_static(b"LATE PART"),
2527        )
2528        .await
2529        .expect_err("an artifact staged against a Failed run must be rejected");
2530        assert_eq!(err.status, StatusCode::GONE);
2531
2532        // The rejected values must not have reached the output tail.
2533        let tail = state.engine.output_tail(&task_id, 1).await;
2534        assert!(tail.is_empty(), "rejected submits must not land: {tail:?}");
2535    }
2536
2537    /// GH #37 fail-open contract: the guard must never turn a
2538    /// would-have-succeeded submit into a failure — no run linkage at
2539    /// all, an unknown Run, and a live (`Running`) Run all pass.
2540    #[tokio::test]
2541    async fn terminal_run_guard_is_fail_open() {
2542        let data_store: Arc<dyn OutputStore> = Arc::new(InMemoryOutputStore::new());
2543        let run_store: Arc<dyn RunStore> = Arc::new(InMemoryRunStore::new());
2544        let state = test_state(data_store, run_store.clone());
2545        let task_id = StepId::new();
2546        seed_task_with_handle(&state, &task_id, "planner", 1, None).await;
2547
2548        // (a) No agent-ctx linkage at all (pre-run-tracking dispatch).
2549        reject_if_run_terminal(&state, &task_id, 1)
2550            .await
2551            .expect("no linkage must fail open");
2552
2553        // (b) Linked to a Run the store does not know.
2554        let unknown_run = RunId::new();
2555        link_task_to_run(&state, &task_id, 1, &unknown_run).await;
2556        reject_if_run_terminal(&state, &task_id, 1)
2557            .await
2558            .expect("unknown run must fail open");
2559
2560        // (c) Linked to a live Run.
2561        let owner_task = TaskId::new();
2562        let live_run = RunId::new();
2563        run_store
2564            .create(run_record(&owner_task, &live_run, vec![]))
2565            .await
2566            .expect("run create");
2567        link_task_to_run(&state, &task_id, 1, &live_run).await;
2568        reject_if_run_terminal(&state, &task_id, 1)
2569            .await
2570            .expect("a Running run must pass the guard");
2571    }
2572
2573    // ──────────────────────────────────────────────────────────────────
2574    // GH #32 — `POST /v1/worker/degradation`
2575    // ──────────────────────────────────────────────────────────────────
2576
2577    fn degradation_body(tool: &str, note: Option<&str>) -> DegradationBody {
2578        DegradationBody {
2579            tool: tool.to_string(),
2580            error: "boom".to_string(),
2581            fallback: "used cached value".to_string(),
2582            note: note.map(str::to_string),
2583        }
2584    }
2585
2586    /// [`link_task_to_run`] plus the `view.agent` name — production's
2587    /// `AgentContextMiddleware` sets both fields on the same `agent_ctx`
2588    /// entry; the shared GH #37 helper only needed `run_id`, so this
2589    /// sibling fills in `agent` too for tests that assert on the
2590    /// server-injected `step_ref`.
2591    async fn link_task_to_run_with_agent(
2592        state: &AppState,
2593        task_id: &StepId,
2594        attempt: u32,
2595        run_id: &RunId,
2596        agent: &str,
2597    ) {
2598        let tid = task_id.clone();
2599        let rid_str = run_id.to_string();
2600        let agent = agent.to_string();
2601        state
2602            .engine
2603            .with_state("test.link_task_to_run_with_agent", move |s| {
2604                let mut entry = mlua_swarm::core::state::AgentCtxEntry::default();
2605                entry.view.run_id = Some(rid_str);
2606                entry.view.agent = agent;
2607                s.agent_ctx.insert((tid, attempt), entry);
2608            })
2609            .await
2610            .expect("link_task_to_run_with_agent");
2611    }
2612
2613    /// A worker-reported degradation is persisted to the linked Run's
2614    /// `degradations` with the server-injected `step_ref` / `attempt` /
2615    /// `at` fields filled in — the client body never supplies any of the
2616    /// three.
2617    #[tokio::test]
2618    async fn worker_degradation_persists_entry_when_run_tracked() {
2619        let data_store: Arc<dyn OutputStore> = Arc::new(InMemoryOutputStore::new());
2620        let run_store: Arc<dyn RunStore> = Arc::new(InMemoryRunStore::new());
2621        let state = test_state(data_store, run_store.clone());
2622        let task_id = StepId::new();
2623        let handle = seed_task_with_handle(&state, &task_id, "planner", 1, None).await;
2624
2625        let owner_task = TaskId::new();
2626        let run_id = RunId::new();
2627        run_store
2628            .create(run_record(&owner_task, &run_id, vec![]))
2629            .await
2630            .expect("run create");
2631        link_task_to_run_with_agent(&state, &task_id, 1, &run_id, "planner").await;
2632
2633        let status = worker_degradation(
2634            State(state.clone()),
2635            bearer_headers(&handle),
2636            Json(degradation_body("web_search", Some("rate limited"))),
2637        )
2638        .await
2639        .expect("worker_degradation");
2640        assert_eq!(status, StatusCode::NO_CONTENT);
2641
2642        let rec = run_store.get(&run_id).await.expect("run get");
2643        assert_eq!(
2644            rec.degradations.len(),
2645            1,
2646            "degradations: {:?}",
2647            rec.degradations
2648        );
2649        let entry = &rec.degradations[0];
2650        assert_eq!(entry.tool, "web_search");
2651        assert_eq!(entry.error, "boom");
2652        assert_eq!(entry.fallback, "used cached value");
2653        assert_eq!(entry.note.as_deref(), Some("rate limited"));
2654        assert_eq!(entry.step_ref.as_deref(), Some("planner"));
2655        assert_eq!(entry.attempt, Some(1));
2656        assert!(entry.at > 0, "at must be a real timestamp: {}", entry.at);
2657    }
2658
2659    /// Two entries POSTed in sequence are appended in order.
2660    #[tokio::test]
2661    async fn worker_degradation_appends_in_order() {
2662        let data_store: Arc<dyn OutputStore> = Arc::new(InMemoryOutputStore::new());
2663        let run_store: Arc<dyn RunStore> = Arc::new(InMemoryRunStore::new());
2664        let state = test_state(data_store, run_store.clone());
2665        let task_id = StepId::new();
2666        let handle = seed_task_with_handle(&state, &task_id, "planner", 1, None).await;
2667
2668        let owner_task = TaskId::new();
2669        let run_id = RunId::new();
2670        run_store
2671            .create(run_record(&owner_task, &run_id, vec![]))
2672            .await
2673            .expect("run create");
2674        link_task_to_run(&state, &task_id, 1, &run_id).await;
2675
2676        for tool in ["first_tool", "second_tool"] {
2677            worker_degradation(
2678                State(state.clone()),
2679                bearer_headers(&handle),
2680                Json(degradation_body(tool, None)),
2681            )
2682            .await
2683            .expect("worker_degradation");
2684        }
2685
2686        let rec = run_store.get(&run_id).await.expect("run get");
2687        let tools: Vec<&str> = rec.degradations.iter().map(|e| e.tool.as_str()).collect();
2688        assert_eq!(tools, vec!["first_tool", "second_tool"]);
2689    }
2690
2691    /// A task whose `agent_ctx` carries no Run linkage (pre-run-tracking
2692    /// dispatch) silently 204s — nothing to append to, and this must not
2693    /// surface as a client error.
2694    #[tokio::test]
2695    async fn worker_degradation_silent_ok_when_no_run_tracked() {
2696        let data_store: Arc<dyn OutputStore> = Arc::new(InMemoryOutputStore::new());
2697        let run_store: Arc<dyn RunStore> = Arc::new(InMemoryRunStore::new());
2698        let state = test_state(data_store, run_store);
2699        let task_id = StepId::new();
2700        let handle = seed_task_with_handle(&state, &task_id, "planner", 1, None).await;
2701
2702        let status = worker_degradation(
2703            State(state.clone()),
2704            bearer_headers(&handle),
2705            Json(degradation_body("some_tool", None)),
2706        )
2707        .await
2708        .expect("worker_degradation must not error on missing run linkage");
2709        assert_eq!(status, StatusCode::NO_CONTENT);
2710    }
2711
2712    /// GH #37 terminal-run guard applies to the degradation channel too — a
2713    /// dead Run must not accumulate signals.
2714    #[tokio::test]
2715    async fn worker_degradation_rejects_terminal_run() {
2716        let data_store: Arc<dyn OutputStore> = Arc::new(InMemoryOutputStore::new());
2717        let run_store: Arc<dyn RunStore> = Arc::new(InMemoryRunStore::new());
2718        let state = test_state(data_store, run_store.clone());
2719        let task_id = StepId::new();
2720        let handle = seed_task_with_handle(&state, &task_id, "planner", 1, None).await;
2721
2722        let owner_task = TaskId::new();
2723        let run_id = RunId::new();
2724        let mut rec = run_record(&owner_task, &run_id, vec![]);
2725        rec.status = RunStatus::Done;
2726        run_store.create(rec).await.expect("run create");
2727        link_task_to_run(&state, &task_id, 1, &run_id).await;
2728
2729        let err = worker_degradation(
2730            State(state.clone()),
2731            bearer_headers(&handle),
2732            Json(degradation_body("some_tool", None)),
2733        )
2734        .await
2735        .expect_err("a degradation against a Done run must be rejected");
2736        assert_eq!(err.status, StatusCode::GONE);
2737
2738        let rec = run_store.get(&run_id).await.expect("run get");
2739        assert!(
2740            rec.degradations.is_empty(),
2741            "rejected degradation must not land: {:?}",
2742            rec.degradations
2743        );
2744    }
2745
2746    // ──────────────────────────────────────────────────────────────────
2747    // GH #42 — `@file:<abs-path>` sentinel resolution in `worker_submit`
2748    // / `worker_artifact`. Guards each verified independently: sentinel
2749    // resolves to the file's trimmed contents; path outside `work_dir`,
2750    // missing file, oversized file, and non-sentinel bodies each get the
2751    // documented behavior.
2752    // ──────────────────────────────────────────────────────────────────
2753
2754    /// Seeds an `agent_ctx` entry whose view carries `work_dir` and, when
2755    /// `allow_file_submit` is `Some`, that value under the GH #43
2756    /// [`FILE_SENTINEL_ALLOW_KEY`] in `view.extra` — matching the shape
2757    /// `AgentContextMiddleware` writes at spawn time. Sentinel resolution
2758    /// requires both the `work_dir` and the strict `Bool(true)` opt-in.
2759    async fn seed_work_dir(
2760        state: &AppState,
2761        task_id: &StepId,
2762        attempt: u32,
2763        work_dir: &str,
2764        allow_file_submit: Option<Value>,
2765    ) {
2766        let tid = task_id.clone();
2767        let work_dir = work_dir.to_string();
2768        state
2769            .engine
2770            .with_state("test.seed_work_dir", move |s| {
2771                let mut entry = mlua_swarm::core::state::AgentCtxEntry::default();
2772                entry.view.work_dir = Some(work_dir);
2773                if let Some(v) = allow_file_submit {
2774                    entry
2775                        .view
2776                        .extra
2777                        .insert(FILE_SENTINEL_ALLOW_KEY.to_string(), v);
2778                }
2779                s.agent_ctx.insert((tid, attempt), entry);
2780            })
2781            .await
2782            .expect("seed_work_dir");
2783    }
2784
2785    /// Sentinel body `@file:<abs-path>` resolves to the file's trimmed
2786    /// contents and reaches the `OutputStore` via the normal Final-append
2787    /// path — same 204 the inline path returns.
2788    #[tokio::test]
2789    async fn worker_submit_resolves_file_sentinel_under_work_dir() {
2790        let data_store: Arc<dyn OutputStore> = Arc::new(InMemoryOutputStore::new());
2791        let run_store: Arc<dyn RunStore> = Arc::new(InMemoryRunStore::new());
2792        let state = test_state(data_store.clone(), run_store);
2793        let task_id = StepId::new();
2794        let handle = seed_task_with_handle(&state, &task_id, "planner", 1, None).await;
2795
2796        let tmp = tempfile::tempdir().expect("tempdir");
2797        let work_dir = tmp.path().to_path_buf();
2798        seed_work_dir(
2799            &state,
2800            &task_id,
2801            1,
2802            work_dir.to_str().expect("work_dir utf-8"),
2803            Some(Value::Bool(true)),
2804        )
2805        .await;
2806
2807        let payload_path = work_dir.join("scout.md");
2808        let payload = "## Context Package (broad)\n\nlarge body content\n";
2809        tokio::fs::write(&payload_path, payload)
2810            .await
2811            .expect("write payload");
2812        let body = format!(
2813            "@file:{}",
2814            payload_path.to_str().expect("payload path utf-8")
2815        );
2816
2817        let status = worker_submit(
2818            State(state.clone()),
2819            bearer_headers(&handle),
2820            Query(SubmitQuery {
2821                ok: None,
2822                verdict: None,
2823            }),
2824            axum::body::Bytes::from(body),
2825        )
2826        .await
2827        .expect("worker_submit sentinel");
2828        assert_eq!(status, StatusCode::NO_CONTENT);
2829
2830        // Final event lands with the file's trimmed contents on
2831        // `EngineState.output_store` (the in-memory tail
2832        // `submit_worker_result_trusted` writes to).
2833        let tid = task_id.clone();
2834        let value = state
2835            .engine
2836            .with_state("test.inspect_output_store", move |s| {
2837                s.output_store.get(&(tid.clone(), 1)).and_then(|evs| {
2838                    evs.iter().find_map(|ev| match ev {
2839                        OutputEvent::Final {
2840                            content: ContentRef::Inline { value },
2841                            ..
2842                        } => Some(value.clone()),
2843                        _ => None,
2844                    })
2845                })
2846            })
2847            .await
2848            .expect("with_state")
2849            .expect("Final event present");
2850        assert_eq!(value, Value::String(payload.trim_end().to_string()));
2851    }
2852
2853    /// A non-sentinel body is passed through byte-for-byte (pre-#42
2854    /// callers see zero behavior change).
2855    #[tokio::test]
2856    async fn worker_submit_passes_non_sentinel_body_unchanged() {
2857        let data_store: Arc<dyn OutputStore> = Arc::new(InMemoryOutputStore::new());
2858        let run_store: Arc<dyn RunStore> = Arc::new(InMemoryRunStore::new());
2859        let state = test_state(data_store.clone(), run_store);
2860        let task_id = StepId::new();
2861        let handle = seed_task_with_handle(&state, &task_id, "planner", 1, None).await;
2862        // No agent_ctx / work_dir seeded — the inline path must not
2863        // require one.
2864
2865        let status = worker_submit(
2866            State(state.clone()),
2867            bearer_headers(&handle),
2868            Query(SubmitQuery {
2869                ok: None,
2870                verdict: None,
2871            }),
2872            axum::body::Bytes::from_static(b"DONE yes=1 maybe=0 no=0"),
2873        )
2874        .await
2875        .expect("worker_submit inline");
2876        assert_eq!(status, StatusCode::NO_CONTENT);
2877
2878        let tid = task_id.clone();
2879        let value = state
2880            .engine
2881            .with_state("test.inspect_output_store", move |s| {
2882                s.output_store.get(&(tid.clone(), 1)).and_then(|evs| {
2883                    evs.iter().find_map(|ev| match ev {
2884                        OutputEvent::Final {
2885                            content: ContentRef::Inline { value },
2886                            ..
2887                        } => Some(value.clone()),
2888                        _ => None,
2889                    })
2890                })
2891            })
2892            .await
2893            .expect("with_state")
2894            .expect("Final event present");
2895        assert_eq!(value, Value::String("DONE yes=1 maybe=0 no=0".to_string()));
2896    }
2897
2898    /// Sentinel with a path outside the task's `work_dir` (`..`-escape
2899    /// via a sibling tempdir) → `400`. `canonicalize` collapses the
2900    /// `..`, so a symlink pointing outside the allowlist would be caught
2901    /// by the same check.
2902    #[tokio::test]
2903    async fn worker_submit_rejects_sentinel_path_outside_work_dir() {
2904        let data_store: Arc<dyn OutputStore> = Arc::new(InMemoryOutputStore::new());
2905        let run_store: Arc<dyn RunStore> = Arc::new(InMemoryRunStore::new());
2906        let state = test_state(data_store, run_store);
2907        let task_id = StepId::new();
2908        let handle = seed_task_with_handle(&state, &task_id, "planner", 1, None).await;
2909
2910        let allowed = tempfile::tempdir().expect("allowed tempdir");
2911        let outside = tempfile::tempdir().expect("outside tempdir");
2912        seed_work_dir(
2913            &state,
2914            &task_id,
2915            1,
2916            allowed.path().to_str().expect("utf-8"),
2917            Some(Value::Bool(true)),
2918        )
2919        .await;
2920
2921        let outside_file = outside.path().join("leak.md");
2922        tokio::fs::write(&outside_file, b"outside content")
2923            .await
2924            .expect("write outside");
2925        let body = format!(
2926            "@file:{}",
2927            outside_file.to_str().expect("outside path utf-8")
2928        );
2929
2930        let err = worker_submit(
2931            State(state.clone()),
2932            bearer_headers(&handle),
2933            Query(SubmitQuery {
2934                ok: None,
2935                verdict: None,
2936            }),
2937            axum::body::Bytes::from(body),
2938        )
2939        .await
2940        .expect_err("outside-work_dir sentinel must be rejected");
2941        assert_eq!(err.status, StatusCode::BAD_REQUEST);
2942    }
2943
2944    /// Sentinel pointing at a non-existent file → `404`.
2945    #[tokio::test]
2946    async fn worker_submit_rejects_sentinel_missing_file() {
2947        let data_store: Arc<dyn OutputStore> = Arc::new(InMemoryOutputStore::new());
2948        let run_store: Arc<dyn RunStore> = Arc::new(InMemoryRunStore::new());
2949        let state = test_state(data_store, run_store);
2950        let task_id = StepId::new();
2951        let handle = seed_task_with_handle(&state, &task_id, "planner", 1, None).await;
2952
2953        let tmp = tempfile::tempdir().expect("tempdir");
2954        seed_work_dir(
2955            &state,
2956            &task_id,
2957            1,
2958            tmp.path().to_str().expect("utf-8"),
2959            Some(Value::Bool(true)),
2960        )
2961        .await;
2962        let missing = tmp.path().join("does-not-exist.md");
2963        let body = format!("@file:{}", missing.to_str().expect("utf-8"));
2964
2965        let err = worker_submit(
2966            State(state.clone()),
2967            bearer_headers(&handle),
2968            Query(SubmitQuery {
2969                ok: None,
2970                verdict: None,
2971            }),
2972            axum::body::Bytes::from(body),
2973        )
2974        .await
2975        .expect_err("missing-file sentinel must be rejected");
2976        assert_eq!(err.status, StatusCode::NOT_FOUND);
2977    }
2978
2979    /// Sentinel body with a relative path → `400` before any FS lookup.
2980    #[tokio::test]
2981    async fn worker_submit_rejects_sentinel_relative_path() {
2982        let data_store: Arc<dyn OutputStore> = Arc::new(InMemoryOutputStore::new());
2983        let run_store: Arc<dyn RunStore> = Arc::new(InMemoryRunStore::new());
2984        let state = test_state(data_store, run_store);
2985        let task_id = StepId::new();
2986        let handle = seed_task_with_handle(&state, &task_id, "planner", 1, None).await;
2987
2988        let err = worker_submit(
2989            State(state.clone()),
2990            bearer_headers(&handle),
2991            Query(SubmitQuery {
2992                ok: None,
2993                verdict: None,
2994            }),
2995            axum::body::Bytes::from_static(b"@file:relative/path.md"),
2996        )
2997        .await
2998        .expect_err("relative-path sentinel must be rejected");
2999        assert_eq!(err.status, StatusCode::BAD_REQUEST);
3000    }
3001
3002    /// Sentinel body when the task has no `AgentContextView` (spawn
3003    /// didn't run through `AgentContextMiddleware`) → `400`. This is the
3004    /// documented pre-condition for sentinel use.
3005    #[tokio::test]
3006    async fn worker_submit_rejects_sentinel_without_agent_context_view() {
3007        let data_store: Arc<dyn OutputStore> = Arc::new(InMemoryOutputStore::new());
3008        let run_store: Arc<dyn RunStore> = Arc::new(InMemoryRunStore::new());
3009        let state = test_state(data_store, run_store);
3010        let task_id = StepId::new();
3011        let handle = seed_task_with_handle(&state, &task_id, "planner", 1, None).await;
3012        // No seed_work_dir — the agent_ctx map has no entry for this task.
3013
3014        let err = worker_submit(
3015            State(state.clone()),
3016            bearer_headers(&handle),
3017            Query(SubmitQuery {
3018                ok: None,
3019                verdict: None,
3020            }),
3021            axum::body::Bytes::from_static(b"@file:/tmp/anywhere.md"),
3022        )
3023        .await
3024        .expect_err("missing AgentContextView must reject sentinel");
3025        assert_eq!(err.status, StatusCode::BAD_REQUEST);
3026    }
3027
3028    /// The same sentinel form works on `POST /v1/worker/artifact` — the
3029    /// artifact endpoint shares the resolver with `worker_submit`, so the
3030    /// resolved file contents land under the artifact's `name` key.
3031    #[tokio::test]
3032    async fn worker_artifact_resolves_file_sentinel_under_work_dir() {
3033        let data_store: Arc<dyn OutputStore> = Arc::new(InMemoryOutputStore::new());
3034        let run_store: Arc<dyn RunStore> = Arc::new(InMemoryRunStore::new());
3035        let state = test_state(data_store, run_store);
3036        let task_id = StepId::new();
3037        let handle = seed_task_with_handle(&state, &task_id, "planner", 1, None).await;
3038
3039        let tmp = tempfile::tempdir().expect("tempdir");
3040        seed_work_dir(
3041            &state,
3042            &task_id,
3043            1,
3044            tmp.path().to_str().expect("utf-8"),
3045            Some(Value::Bool(true)),
3046        )
3047        .await;
3048
3049        let payload_path = tmp.path().join("part.md");
3050        let payload = "artifact part body\n";
3051        tokio::fs::write(&payload_path, payload)
3052            .await
3053            .expect("write payload");
3054        let body = format!("@file:{}", payload_path.to_str().expect("utf-8"));
3055
3056        let status = worker_artifact(
3057            State(state.clone()),
3058            bearer_headers(&handle),
3059            Query(ArtifactQuery {
3060                name: "scout".to_string(),
3061            }),
3062            axum::body::Bytes::from(body),
3063        )
3064        .await
3065        .expect("worker_artifact sentinel");
3066        assert_eq!(status, StatusCode::NO_CONTENT);
3067    }
3068
3069    /// GH #43 — sentinel with `work_dir` seeded but no
3070    /// `allow_file_submit` opt-in → `400` (default-deny). The file exists
3071    /// and sits under `work_dir`, so the rejection is attributable to the
3072    /// missing opt-in alone.
3073    #[tokio::test]
3074    async fn worker_submit_rejects_sentinel_without_allow_flag() {
3075        let data_store: Arc<dyn OutputStore> = Arc::new(InMemoryOutputStore::new());
3076        let run_store: Arc<dyn RunStore> = Arc::new(InMemoryRunStore::new());
3077        let state = test_state(data_store, run_store);
3078        let task_id = StepId::new();
3079        let handle = seed_task_with_handle(&state, &task_id, "planner", 1, None).await;
3080
3081        let tmp = tempfile::tempdir().expect("tempdir");
3082        seed_work_dir(
3083            &state,
3084            &task_id,
3085            1,
3086            tmp.path().to_str().expect("utf-8"),
3087            None,
3088        )
3089        .await;
3090
3091        let payload_path = tmp.path().join("out.md");
3092        tokio::fs::write(&payload_path, b"resolvable body")
3093            .await
3094            .expect("write payload");
3095        let body = format!("@file:{}", payload_path.to_str().expect("utf-8"));
3096
3097        let err = worker_submit(
3098            State(state.clone()),
3099            bearer_headers(&handle),
3100            Query(SubmitQuery {
3101                ok: None,
3102                verdict: None,
3103            }),
3104            axum::body::Bytes::from(body),
3105        )
3106        .await
3107        .expect_err("missing opt-in must reject sentinel");
3108        assert_eq!(err.status, StatusCode::BAD_REQUEST);
3109        assert!(
3110            err.message.contains("not allowed"),
3111            "rejection must name the opt-in guard, got: {}",
3112            err.message
3113        );
3114    }
3115
3116    /// GH #43 — the opt-in is the strict boolean `true`: `Bool(false)`
3117    /// and the string `"true"` are both rejected with `400`.
3118    #[tokio::test]
3119    async fn worker_submit_rejects_sentinel_with_non_true_allow_values() {
3120        for allow in [Value::Bool(false), Value::String("true".to_string())] {
3121            let data_store: Arc<dyn OutputStore> = Arc::new(InMemoryOutputStore::new());
3122            let run_store: Arc<dyn RunStore> = Arc::new(InMemoryRunStore::new());
3123            let state = test_state(data_store, run_store);
3124            let task_id = StepId::new();
3125            let handle = seed_task_with_handle(&state, &task_id, "planner", 1, None).await;
3126
3127            let tmp = tempfile::tempdir().expect("tempdir");
3128            seed_work_dir(
3129                &state,
3130                &task_id,
3131                1,
3132                tmp.path().to_str().expect("utf-8"),
3133                Some(allow.clone()),
3134            )
3135            .await;
3136
3137            let payload_path = tmp.path().join("out.md");
3138            tokio::fs::write(&payload_path, b"resolvable body")
3139                .await
3140                .expect("write payload");
3141            let body = format!("@file:{}", payload_path.to_str().expect("utf-8"));
3142
3143            let err = worker_submit(
3144                State(state.clone()),
3145                bearer_headers(&handle),
3146                Query(SubmitQuery {
3147                    ok: None,
3148                    verdict: None,
3149                }),
3150                axum::body::Bytes::from(body),
3151            )
3152            .await
3153            .expect_err("non-true opt-in value must reject sentinel");
3154            assert_eq!(err.status, StatusCode::BAD_REQUEST, "value: {allow:?}");
3155        }
3156    }
3157
3158    // ──────────────────────────────────────────────────────────────────
3159    // `submit_format` — the submit-route half of the contract. Default
3160    // (undeclared) STAGES `Value::String` exactly as before (the lenient
3161    // container parse is the engine fold's job, not this route's);
3162    // `"json"` parses here (any JSON value) and rejects `422` when the
3163    // body does not parse; `"text"` is a recognized no-op here whose
3164    // effect lives in the engine fold (`FoldParse::Raw`).
3165    // ──────────────────────────────────────────────────────────────────
3166
3167    /// Seeds an `agent_ctx` entry carrying the agent name plus, when
3168    /// `submit_format` is `Some`, that value under [`SUBMIT_FORMAT_KEY`]
3169    /// in `view.extra` — the shape `AgentContextMiddleware` folds from
3170    /// the Blueprint meta channels at spawn time. `work_dir`, when given,
3171    /// also enables the `@file:` sentinel (`allow_file_submit: true`), so
3172    /// one helper covers the sentinel + parse combination.
3173    async fn seed_submit_format(
3174        state: &AppState,
3175        task_id: &StepId,
3176        attempt: u32,
3177        agent: &str,
3178        submit_format: Option<Value>,
3179        work_dir: Option<&str>,
3180    ) {
3181        let tid = task_id.clone();
3182        let agent = agent.to_string();
3183        let work_dir = work_dir.map(|w| w.to_string());
3184        state
3185            .engine
3186            .with_state("test.seed_submit_format", move |s| {
3187                let mut entry = mlua_swarm::core::state::AgentCtxEntry::default();
3188                entry.view.agent = agent;
3189                if let Some(w) = work_dir {
3190                    entry.view.work_dir = Some(w);
3191                    entry
3192                        .view
3193                        .extra
3194                        .insert(FILE_SENTINEL_ALLOW_KEY.to_string(), Value::Bool(true));
3195                }
3196                if let Some(v) = submit_format {
3197                    entry.view.extra.insert(SUBMIT_FORMAT_KEY.to_string(), v);
3198                }
3199                s.agent_ctx.insert((tid, attempt), entry);
3200            })
3201            .await
3202            .expect("seed_submit_format");
3203    }
3204
3205    /// Reads back the `Final` event's value from the in-memory
3206    /// `output_store` tail `submit_worker_result_trusted` writes to.
3207    async fn final_value(state: &AppState, task_id: &StepId, attempt: u32) -> Option<Value> {
3208        let tid = task_id.clone();
3209        state
3210            .engine
3211            .with_state("test.inspect_output_store", move |s| {
3212                s.output_store.get(&(tid.clone(), attempt)).and_then(|evs| {
3213                    evs.iter().find_map(|ev| match ev {
3214                        OutputEvent::Final {
3215                            content: ContentRef::Inline { value },
3216                            ..
3217                        } => Some(value.clone()),
3218                        _ => None,
3219                    })
3220                })
3221            })
3222            .await
3223            .expect("with_state")
3224    }
3225
3226    /// Route-level regression lock: a step with a materialized view but
3227    /// NO `submit_format` declaration STAGES its body as a string even
3228    /// when that body happens to be valid JSON — this route never sniffs
3229    /// the payload. (The default lenient container parse happens later,
3230    /// at the engine's Final-pull fold — `FoldParse::Lenient`, tested in
3231    /// `mlua_swarm::core::engine` — which is exactly why the staged bytes
3232    /// here must stay raw: they are what `materialize_final_submission` /
3233    /// `materialize_part` and the verdict-contract checks see.)
3234    #[tokio::test]
3235    async fn worker_submit_without_submit_format_stages_json_looking_body_as_string() {
3236        let data_store: Arc<dyn OutputStore> = Arc::new(InMemoryOutputStore::new());
3237        let run_store: Arc<dyn RunStore> = Arc::new(InMemoryRunStore::new());
3238        let state = test_state(data_store, run_store);
3239        let task_id = StepId::new();
3240        let handle = seed_task_with_handle(&state, &task_id, "planner", 1, None).await;
3241        seed_submit_format(&state, &task_id, 1, "planner", None, None).await;
3242
3243        let body = r#"{"lanes":["a","b"]}"#;
3244        let status = worker_submit(
3245            State(state.clone()),
3246            bearer_headers(&handle),
3247            Query(SubmitQuery {
3248                ok: None,
3249                verdict: None,
3250            }),
3251            axum::body::Bytes::from(body),
3252        )
3253        .await
3254        .expect("undeclared submit must succeed");
3255        assert_eq!(status, StatusCode::NO_CONTENT);
3256
3257        assert_eq!(
3258            final_value(&state, &task_id, 1).await,
3259            Some(Value::String(body.to_string())),
3260            "an undeclared step must keep the raw-string fold",
3261        );
3262    }
3263
3264    /// `submit_format: "json"` + a parseable body → the folded value is
3265    /// the structured JSON, so a downstream path (`$.<step>.lanes`)
3266    /// resolves instead of hitting one opaque string.
3267    #[tokio::test]
3268    async fn worker_submit_declared_json_folds_structured_value() {
3269        let data_store: Arc<dyn OutputStore> = Arc::new(InMemoryOutputStore::new());
3270        let run_store: Arc<dyn RunStore> = Arc::new(InMemoryRunStore::new());
3271        let state = test_state(data_store, run_store);
3272        let task_id = StepId::new();
3273        let handle = seed_task_with_handle(&state, &task_id, "planner", 1, None).await;
3274        seed_submit_format(
3275            &state,
3276            &task_id,
3277            1,
3278            "planner",
3279            Some(Value::String(SUBMIT_FORMAT_JSON.to_string())),
3280            None,
3281        )
3282        .await;
3283
3284        let status = worker_submit(
3285            State(state.clone()),
3286            bearer_headers(&handle),
3287            Query(SubmitQuery {
3288                ok: None,
3289                verdict: None,
3290            }),
3291            axum::body::Bytes::from(r#"{"lanes":["auth","billing"],"verdict":"PASS"}"#),
3292        )
3293        .await
3294        .expect("declared JSON submit must succeed");
3295        assert_eq!(status, StatusCode::NO_CONTENT);
3296
3297        let value = final_value(&state, &task_id, 1)
3298            .await
3299            .expect("Final event present");
3300        assert_eq!(
3301            value,
3302            json!({"lanes": ["auth", "billing"], "verdict": "PASS"})
3303        );
3304        // The whole point of the opt-in: fields are addressable.
3305        assert_eq!(value["lanes"], json!(["auth", "billing"]));
3306        assert_eq!(value["verdict"], json!("PASS"));
3307    }
3308
3309    /// Declared-strict: a declared step whose body does not parse is
3310    /// rejected with `422` (naming the agent and echoing the body head),
3311    /// and nothing reaches the flow ctx.
3312    #[tokio::test]
3313    async fn worker_submit_declared_json_rejects_unparseable_body_with_422() {
3314        let data_store: Arc<dyn OutputStore> = Arc::new(InMemoryOutputStore::new());
3315        let run_store: Arc<dyn RunStore> = Arc::new(InMemoryRunStore::new());
3316        let state = test_state(data_store, run_store);
3317        let task_id = StepId::new();
3318        let handle = seed_task_with_handle(&state, &task_id, "planner", 1, None).await;
3319        seed_submit_format(
3320            &state,
3321            &task_id,
3322            1,
3323            "planner",
3324            Some(Value::String(SUBMIT_FORMAT_JSON.to_string())),
3325            None,
3326        )
3327        .await;
3328
3329        let err = worker_submit(
3330            State(state.clone()),
3331            bearer_headers(&handle),
3332            Query(SubmitQuery {
3333                ok: None,
3334                verdict: None,
3335            }),
3336            axum::body::Bytes::from("DONE — 3 lanes planned, see the report above"),
3337        )
3338        .await
3339        .expect_err("a declared step must not fold an unparseable body");
3340        assert_eq!(err.status, StatusCode::UNPROCESSABLE_ENTITY);
3341        assert!(
3342            err.message.contains("planner") && err.message.contains("DONE"),
3343            "the rejection must name the agent and echo the body head, got: {}",
3344            err.message
3345        );
3346        assert_eq!(
3347            final_value(&state, &task_id, 1).await,
3348            None,
3349            "a rejected submit must not reach the output tail",
3350        );
3351    }
3352
3353    /// The `@file:` sentinel and the parse compose: the file is resolved
3354    /// first, then its contents are parsed, so a large structured payload
3355    /// can take the file lane without losing its shape.
3356    #[tokio::test]
3357    async fn worker_submit_declared_json_parses_file_sentinel_contents() {
3358        let data_store: Arc<dyn OutputStore> = Arc::new(InMemoryOutputStore::new());
3359        let run_store: Arc<dyn RunStore> = Arc::new(InMemoryRunStore::new());
3360        let state = test_state(data_store, run_store);
3361        let task_id = StepId::new();
3362        let handle = seed_task_with_handle(&state, &task_id, "planner", 1, None).await;
3363
3364        let tmp = tempfile::tempdir().expect("tempdir");
3365        let work_dir = tmp.path().to_path_buf();
3366        seed_submit_format(
3367            &state,
3368            &task_id,
3369            1,
3370            "planner",
3371            Some(Value::String(SUBMIT_FORMAT_JSON.to_string())),
3372            Some(work_dir.to_str().expect("work_dir utf-8")),
3373        )
3374        .await;
3375
3376        let payload_path = work_dir.join("plan.json");
3377        tokio::fs::write(&payload_path, "{\"lanes\": [\"auth\", \"billing\"]}\n")
3378            .await
3379            .expect("write payload");
3380        let body = format!(
3381            "@file:{}",
3382            payload_path.to_str().expect("payload path utf-8")
3383        );
3384
3385        let status = worker_submit(
3386            State(state.clone()),
3387            bearer_headers(&handle),
3388            Query(SubmitQuery {
3389                ok: None,
3390                verdict: None,
3391            }),
3392            axum::body::Bytes::from(body),
3393        )
3394        .await
3395        .expect("sentinel + declared JSON submit must succeed");
3396        assert_eq!(status, StatusCode::NO_CONTENT);
3397
3398        assert_eq!(
3399            final_value(&state, &task_id, 1).await,
3400            Some(json!({"lanes": ["auth", "billing"]})),
3401        );
3402    }
3403
3404    /// An unrecognized declared value is not a client error: the body
3405    /// folds as a string (the default lane) and the server warns. Locks
3406    /// the fallback so a typo degrades visibly instead of 422-ing a
3407    /// worker that did nothing wrong.
3408    #[tokio::test]
3409    async fn worker_submit_unknown_submit_format_falls_back_to_string() {
3410        for declared in [
3411            Value::String("yaml".to_string()),
3412            Value::Bool(true),
3413            Value::Number(1.into()),
3414        ] {
3415            let data_store: Arc<dyn OutputStore> = Arc::new(InMemoryOutputStore::new());
3416            let run_store: Arc<dyn RunStore> = Arc::new(InMemoryRunStore::new());
3417            let state = test_state(data_store, run_store);
3418            let task_id = StepId::new();
3419            let handle = seed_task_with_handle(&state, &task_id, "planner", 1, None).await;
3420            seed_submit_format(&state, &task_id, 1, "planner", Some(declared.clone()), None).await;
3421
3422            let status = worker_submit(
3423                State(state.clone()),
3424                bearer_headers(&handle),
3425                Query(SubmitQuery {
3426                    ok: None,
3427                    verdict: None,
3428                }),
3429                axum::body::Bytes::from(r#"{"lanes":["a"]}"#),
3430            )
3431            .await
3432            .unwrap_or_else(|e| panic!("unknown value must not reject ({declared}): {e:?}"));
3433            assert_eq!(status, StatusCode::NO_CONTENT);
3434
3435            assert_eq!(
3436                final_value(&state, &task_id, 1).await,
3437                Some(Value::String(r#"{"lanes":["a"]}"#.to_string())),
3438                "unknown value {declared} must keep the string fold",
3439            );
3440        }
3441    }
3442
3443    /// `submit_format: "text"` is a recognized value at this route: the
3444    /// body stages as a string (like undeclared), succeeds, and is NOT
3445    /// the unknown-value warn path. Its real effect — opting the step's
3446    /// fold out of the lenient container parse — is the engine fold's
3447    /// job (`FoldParse::Raw`, tested in `mlua_swarm::core::engine`).
3448    #[tokio::test]
3449    async fn worker_submit_declared_text_stages_string() {
3450        let data_store: Arc<dyn OutputStore> = Arc::new(InMemoryOutputStore::new());
3451        let run_store: Arc<dyn RunStore> = Arc::new(InMemoryRunStore::new());
3452        let state = test_state(data_store, run_store);
3453        let task_id = StepId::new();
3454        let handle = seed_task_with_handle(&state, &task_id, "planner", 1, None).await;
3455        seed_submit_format(
3456            &state,
3457            &task_id,
3458            1,
3459            "planner",
3460            Some(Value::String("text".to_string())),
3461            None,
3462        )
3463        .await;
3464
3465        let body = r#"{"lanes":["a","b"]}"#;
3466        let status = worker_submit(
3467            State(state.clone()),
3468            bearer_headers(&handle),
3469            Query(SubmitQuery {
3470                ok: None,
3471                verdict: None,
3472            }),
3473            axum::body::Bytes::from(body),
3474        )
3475        .await
3476        .expect("text-declared submit must succeed");
3477        assert_eq!(status, StatusCode::NO_CONTENT);
3478
3479        assert_eq!(
3480            final_value(&state, &task_id, 1).await,
3481            Some(Value::String(body.to_string())),
3482            "a text-declared step must stage the raw string",
3483        );
3484    }
3485
3486    /// Order lock: the verdict contract still sees the pre-parse string
3487    /// for an undeclared gate agent — a `channel: "body"` contract keeps
3488    /// accepting its bare token and keeps rejecting a non-member value,
3489    /// byte-for-byte as before the opt-in existed.
3490    #[tokio::test]
3491    async fn worker_submit_verdict_contract_unchanged_without_submit_format() {
3492        let data_store: Arc<dyn OutputStore> = Arc::new(InMemoryOutputStore::new());
3493        let run_store: Arc<dyn RunStore> = Arc::new(InMemoryRunStore::new());
3494        let state = test_state(data_store, run_store);
3495        state.engine.register_verdict_contracts(HashMap::from([(
3496            "gate".to_string(),
3497            body_verdict_contract(&["PASS", "BLOCKED"]),
3498        )]));
3499
3500        // Member value: accepted, folded as the same bare string.
3501        let accepted = StepId::new();
3502        let handle = seed_task_with_handle(&state, &accepted, "gate", 1, None).await;
3503        seed_submit_format(&state, &accepted, 1, "gate", None, None).await;
3504        let status = worker_submit(
3505            State(state.clone()),
3506            bearer_headers(&handle),
3507            Query(SubmitQuery {
3508                ok: None,
3509                verdict: None,
3510            }),
3511            axum::body::Bytes::from("BLOCKED"),
3512        )
3513        .await
3514        .expect("a declared verdict value must still pass");
3515        assert_eq!(status, StatusCode::NO_CONTENT);
3516        assert_eq!(
3517            final_value(&state, &accepted, 1).await,
3518            Some(Value::String("BLOCKED".to_string())),
3519        );
3520
3521        // Non-member value: still the pre-existing 422 from the contract,
3522        // not a submit_format error.
3523        let rejected = StepId::new();
3524        let handle = seed_task_with_handle(&state, &rejected, "gate", 1, None).await;
3525        seed_submit_format(&state, &rejected, 1, "gate", None, None).await;
3526        let err = worker_submit(
3527            State(state.clone()),
3528            bearer_headers(&handle),
3529            Query(SubmitQuery {
3530                ok: None,
3531                verdict: None,
3532            }),
3533            axum::body::Bytes::from("UNKNOWN"),
3534        )
3535        .await
3536        .expect_err("a non-member verdict value must still be rejected");
3537        assert_eq!(err.status, StatusCode::UNPROCESSABLE_ENTITY);
3538        assert!(
3539            err.message.contains("verdict contract violation"),
3540            "the verdict contract must own this rejection, got: {}",
3541            err.message
3542        );
3543    }
3544
3545    /// End-to-end of the motivating shape: a planner declares
3546    /// `submit_format: "json"`, submits `{"lanes": [...]}`, and a `fanout`
3547    /// whose `items` is `$.<step>.lanes` dispatches one lane per element.
3548    /// Before the opt-in the same submit folded as a string and the
3549    /// `items` path could not be resolved at all.
3550    #[tokio::test]
3551    async fn declared_json_submit_feeds_a_fanout_items_path() {
3552        use mlua_flow_ir::{EvalError, Expr, JoinMode, Node as FlowNode};
3553
3554        let data_store: Arc<dyn OutputStore> = Arc::new(InMemoryOutputStore::new());
3555        let run_store: Arc<dyn RunStore> = Arc::new(InMemoryRunStore::new());
3556        let state = test_state(data_store, run_store);
3557        let task_id = StepId::new();
3558        let handle = seed_task_with_handle(&state, &task_id, "planner", 1, None).await;
3559        seed_submit_format(
3560            &state,
3561            &task_id,
3562            1,
3563            "planner",
3564            Some(Value::String(SUBMIT_FORMAT_JSON.to_string())),
3565            None,
3566        )
3567        .await;
3568
3569        worker_submit(
3570            State(state.clone()),
3571            bearer_headers(&handle),
3572            Query(SubmitQuery {
3573                ok: None,
3574                verdict: None,
3575            }),
3576            axum::body::Bytes::from(r#"{"lanes":["auth","billing","search"]}"#),
3577        )
3578        .await
3579        .expect("declared JSON submit must succeed");
3580        let planner_out = final_value(&state, &task_id, 1)
3581            .await
3582            .expect("Final event present");
3583
3584        // The step's OUTPUT as the BP chain would see it, under `$.planner`.
3585        let path = |s: &str| Expr::Path {
3586            at: s.parse().expect("literal test path"),
3587        };
3588        let flow = FlowNode::Fanout {
3589            items: path("$.planner.lanes"),
3590            bind: path("$.item"),
3591            body: Box::new(FlowNode::Step {
3592                ref_: "check".to_string(),
3593                in_: path("$.item"),
3594                out: path("$.branch_out"),
3595            }),
3596            join: JoinMode::All,
3597            out: path("$.results"),
3598        };
3599        let dispatcher = |_ref: &str, input: Value| -> Result<Value, EvalError> { Ok(input) };
3600        let final_ctx = mlua_flow_ir::eval(&flow, json!({ "planner": planner_out }), &dispatcher)
3601            .expect("fanout over the parsed submit must evaluate");
3602
3603        let lanes: Vec<&Value> = final_ctx["results"]
3604            .as_array()
3605            .expect("results is an array")
3606            .iter()
3607            .map(|lane_ctx| &lane_ctx["branch_out"])
3608            .collect();
3609        assert_eq!(
3610            lanes,
3611            vec![&json!("auth"), &json!("billing"), &json!("search")]
3612        );
3613    }
3614
3615    // ──────────────────────────────────────────────────────────────────
3616    // GH #50 (Subtask 2) — submit-time verdict contract gate, handler-
3617    // level unit coverage. The full process-boundary HTTP round trip
3618    // (Acceptance Criterion #7) lives in
3619    // `crates/mlua-swarm-server/tests/verdict_contract.rs`; these are the
3620    // fast in-process counterpart exercising `worker_submit` /
3621    // `worker_artifact` directly, same convention as the sentinel tests
3622    // above.
3623    // ──────────────────────────────────────────────────────────────────
3624
3625    fn body_verdict_contract(values: &[&str]) -> mlua_swarm_schema::VerdictContract {
3626        mlua_swarm_schema::VerdictContract {
3627            channel: VerdictChannel::Body,
3628            values: values.iter().map(|v| v.to_string()).collect(),
3629        }
3630    }
3631
3632    fn part_verdict_contract(values: &[&str]) -> mlua_swarm_schema::VerdictContract {
3633        mlua_swarm_schema::VerdictContract {
3634            channel: VerdictChannel::Part,
3635            values: values.iter().map(|v| v.to_string()).collect(),
3636        }
3637    }
3638
3639    /// A `channel: "body"` contract rejects a `worker_submit` body outside
3640    /// its declared `values` with `422`, echoing the expected token set.
3641    #[tokio::test]
3642    async fn worker_submit_rejects_body_outside_contract_values_with_422() {
3643        let data_store: Arc<dyn OutputStore> = Arc::new(InMemoryOutputStore::new());
3644        let run_store: Arc<dyn RunStore> = Arc::new(InMemoryRunStore::new());
3645        let state = test_state(data_store, run_store);
3646        let task_id = StepId::new();
3647        let handle = seed_task_with_handle(&state, &task_id, "gate", 1, None).await;
3648        state.engine.register_verdict_contracts(HashMap::from([(
3649            "gate".to_string(),
3650            body_verdict_contract(&["PASS", "BLOCKED"]),
3651        )]));
3652
3653        let err = worker_submit(
3654            State(state.clone()),
3655            bearer_headers(&handle),
3656            Query(SubmitQuery {
3657                ok: None,
3658                verdict: None,
3659            }),
3660            axum::body::Bytes::from("UNKNOWN"),
3661        )
3662        .await
3663        .expect_err("value outside declared values must reject");
3664        assert_eq!(err.status, StatusCode::UNPROCESSABLE_ENTITY);
3665        assert!(
3666            err.message.contains("PASS") && err.message.contains("BLOCKED"),
3667            "rejection must echo the declared values, got: {}",
3668            err.message
3669        );
3670    }
3671
3672    /// The same contract accepts a body that IS a member of `values` —
3673    /// `204`, unaffected submit.
3674    #[tokio::test]
3675    async fn worker_submit_accepts_body_inside_contract_values() {
3676        let data_store: Arc<dyn OutputStore> = Arc::new(InMemoryOutputStore::new());
3677        let run_store: Arc<dyn RunStore> = Arc::new(InMemoryRunStore::new());
3678        let state = test_state(data_store, run_store);
3679        let task_id = StepId::new();
3680        let handle = seed_task_with_handle(&state, &task_id, "gate", 1, None).await;
3681        state.engine.register_verdict_contracts(HashMap::from([(
3682            "gate".to_string(),
3683            body_verdict_contract(&["PASS", "BLOCKED"]),
3684        )]));
3685
3686        let status = worker_submit(
3687            State(state.clone()),
3688            bearer_headers(&handle),
3689            Query(SubmitQuery {
3690                ok: None,
3691                verdict: None,
3692            }),
3693            axum::body::Bytes::from("PASS"),
3694        )
3695        .await
3696        .expect("value inside declared values must succeed");
3697        assert_eq!(status, StatusCode::NO_CONTENT);
3698    }
3699
3700    /// Opt-in regression guard: an agent with NO declared verdict contract
3701    /// is entirely unaffected — `worker_submit` still returns `204` for an
3702    /// arbitrary body, exactly the pre-GH-#50 behavior.
3703    #[tokio::test]
3704    async fn worker_submit_without_a_declared_contract_is_unaffected() {
3705        let data_store: Arc<dyn OutputStore> = Arc::new(InMemoryOutputStore::new());
3706        let run_store: Arc<dyn RunStore> = Arc::new(InMemoryRunStore::new());
3707        let state = test_state(data_store, run_store);
3708        let task_id = StepId::new();
3709        // No `register_verdict_contracts` call — the agent declared no contract.
3710        let handle = seed_task_with_handle(&state, &task_id, "undeclared-agent", 1, None).await;
3711
3712        let status = worker_submit(
3713            State(state.clone()),
3714            bearer_headers(&handle),
3715            Query(SubmitQuery {
3716                ok: None,
3717                verdict: None,
3718            }),
3719            axum::body::Bytes::from("anything at all, no contract to violate"),
3720        )
3721        .await
3722        .expect("no contract declared must never reject");
3723        assert_eq!(status, StatusCode::NO_CONTENT);
3724    }
3725
3726    /// A `channel: "part"` contract rejects a `worker_artifact?name=verdict`
3727    /// value outside `values` with `422`.
3728    #[tokio::test]
3729    async fn worker_artifact_verdict_part_rejects_value_outside_contract_with_422() {
3730        let data_store: Arc<dyn OutputStore> = Arc::new(InMemoryOutputStore::new());
3731        let run_store: Arc<dyn RunStore> = Arc::new(InMemoryRunStore::new());
3732        let state = test_state(data_store, run_store);
3733        let task_id = StepId::new();
3734        let handle = seed_task_with_handle(&state, &task_id, "gate", 1, None).await;
3735        state.engine.register_verdict_contracts(HashMap::from([(
3736            "gate".to_string(),
3737            part_verdict_contract(&["PASS", "BLOCKED"]),
3738        )]));
3739
3740        let err = worker_artifact(
3741            State(state.clone()),
3742            bearer_headers(&handle),
3743            Query(ArtifactQuery {
3744                name: "verdict".to_string(),
3745            }),
3746            axum::body::Bytes::from("UNKNOWN"),
3747        )
3748        .await
3749        .expect_err("value outside declared values must reject");
3750        assert_eq!(err.status, StatusCode::UNPROCESSABLE_ENTITY);
3751    }
3752
3753    /// A part named anything OTHER than `"verdict"` skips the gate
3754    /// entirely, even with a `channel: "part"` contract declared — `204`,
3755    /// existing pre-GH-#50 behavior unchanged.
3756    #[tokio::test]
3757    async fn worker_artifact_non_verdict_part_skips_the_gate() {
3758        let data_store: Arc<dyn OutputStore> = Arc::new(InMemoryOutputStore::new());
3759        let run_store: Arc<dyn RunStore> = Arc::new(InMemoryRunStore::new());
3760        let state = test_state(data_store, run_store);
3761        let task_id = StepId::new();
3762        let handle = seed_task_with_handle(&state, &task_id, "gate", 1, None).await;
3763        state.engine.register_verdict_contracts(HashMap::from([(
3764            "gate".to_string(),
3765            part_verdict_contract(&["PASS", "BLOCKED"]),
3766        )]));
3767
3768        let status = worker_artifact(
3769            State(state.clone()),
3770            bearer_headers(&handle),
3771            Query(ArtifactQuery {
3772                name: "notes".to_string(),
3773            }),
3774            axum::body::Bytes::from("anything at all"),
3775        )
3776        .await
3777        .expect("non-verdict part name must never be gated");
3778        assert_eq!(status, StatusCode::NO_CONTENT);
3779    }
3780}