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