Skip to main content

mlua_swarm/core/
engine.rs

1//! `Engine` — the long-running stateful runtime plus the `with_state`
2//! helper (R1-R4 discipline).
3//!
4//! The engine owns the Domain side of the Data / Domain split:
5//! flow control (dispatch / verdict), state (`EngineState`), and the
6//! `submit_output` / `output_tail` surface that feeds it. Data-plane
7//! traffic (Big Response bodies) is delegated to the `output_store` module
8//! plus its paired `SpawnerLayer`s and passes through here without the
9//! engine core needing to grow.
10
11use crate::core::agent_context::{RUN_ID_KEY, STEP_CTX_KEY};
12use crate::core::config::EngineCfg;
13use crate::core::ctx::{Ctx, OperatorInfo, OperatorKind, SeniorBridge, SpawnHook};
14use crate::core::errors::EngineError;
15use crate::core::state::{
16    CapTokenRecord, DispatchOutcome, EngineState, Event, EventStream, OperatorSession, ResumeKey,
17    ResumePending, TaskSpec, TaskState, TaskStatus,
18};
19use crate::types::{
20    default_role_verb_table, now_unix, CapToken, Role, RoleVerbGate, RunId, SessionId, StepId,
21    TokenSigner, Verb,
22};
23use crate::worker::adapter::SpawnerAdapter;
24use serde_json::Value;
25use std::collections::HashMap;
26use std::sync::Arc;
27use std::time::{Duration, Instant};
28use tokio::sync::{broadcast, Mutex};
29
30/// Process-wide long-running runtime. Cheap to `clone()` — an `Arc`
31/// lives inside.
32#[derive(Clone)]
33pub struct Engine {
34    inner: Arc<EngineInner>,
35}
36
37struct EngineInner {
38    state: Mutex<EngineState>,
39    cfg: EngineCfg,
40    signer: TokenSigner,
41    gate: RoleVerbGate,
42    event_tx: broadcast::Sender<Event>,
43    /// ID-keyed bridge registry (register-by-ID design). `SeniorBridge`
44    /// and `SpawnHook` are registered by ID; sessions bind to those IDs
45    /// only. Persistence stores just the ID, and on reattach the caller
46    /// re-registers under the same ID to restore presence.
47    senior_bridges: tokio::sync::RwLock<HashMap<String, Arc<dyn SeniorBridge>>>,
48    spawn_hooks: tokio::sync::RwLock<HashMap<String, Arc<dyn SpawnHook>>>,
49    /// ID registry for full-spawn Operator backends (backends that take the
50    /// entire spawn via `execute`). Sibling to `senior_bridges` /
51    /// `spawn_hooks`. `OperatorDelegateMiddleware` looks these up via
52    /// `ctx` and, when `kind = MainAi` / `Composite`, bypasses
53    /// `inner.spawn` and calls `operator.execute` instead.
54    operators: tokio::sync::RwLock<HashMap<String, Arc<dyn crate::operator::Operator>>>,
55    /// Base and hint layer factories for the `SpawnerStack`. At
56    /// `service::linker::link` time, `compiled.router` is wrapped with
57    /// the base factories plus the hint factories resolved from
58    /// `blueprint.spawner_hints.layers`. This is the engine-side
59    /// counterpart to the discipline "Flow / Blueprint doesn't spell out
60    /// middleware implementations — it declares the capabilities it needs
61    /// as hint keys".
62    layer_registry: crate::middleware::LayerRegistry,
63    /// Optional Data-plane `OutputStore` backend (subtask-4 / ST2 rework —
64    /// see `submit_output`'s doc). `None` (the default) preserves
65    /// pre-subtask-4 behavior exactly: `submit_output` /
66    /// `submit_worker_result_trusted` only touch the Domain-plane
67    /// `EngineState.output_store` HashMap, same as before this was added.
68    /// `Some` additionally dual-writes every `Final` event into this store
69    /// via [`crate::store::output::OutputStore::append`], making it
70    /// queryable (e.g. by `mlua-swarm-server`'s `GET /v1/tasks/:id/ctx`)
71    /// even for an in-flight run. A plain `std::sync::RwLock` (not
72    /// `tokio::sync::RwLock`) — set once at boot via [`Engine::set_output_store`]
73    /// from a synchronous call site (`mlua-swarm-server`'s router builder),
74    /// then only ever briefly read (clone the `Option<Arc<..>>`, never held
75    /// across an `.await`) from the async submit path.
76    data_store: std::sync::RwLock<Option<Arc<dyn crate::store::output::OutputStore>>>,
77    /// GH #50 (Subtask 2 — runtime plumbing): agent name → declared
78    /// [`mlua_swarm_schema::VerdictContract`], the Engine-side registry
79    /// [`Self::verdict_contract_for_task`] resolves against. Populated via
80    /// [`Self::register_verdict_contracts`] — same sync-`RwLock`,
81    /// set-outside-the-lock idiom as `data_store` above. Empty by default
82    /// (every pre-GH-#50 `Engine`), which is exactly the opt-in "no
83    /// contract declared" state `verdict_contract_for_task` treats as
84    /// `None`. Populated from a live `Compiler::compile`'s
85    /// `CompiledAgentTable.verdict_contracts` output by
86    /// `TaskLaunchService::launch`, immediately after `compiler.compile`
87    /// succeeds — see [`Self::register_verdict_contracts`]'s doc for the
88    /// overwrite semantics of that merge.
89    verdict_contracts: std::sync::RwLock<HashMap<String, mlua_swarm_schema::VerdictContract>>,
90}
91
92/// Renders a `TaskSpec.initial_directive` / `EngineState.prompts`
93/// `Value` down to the `String` shape that string-consuming boundaries
94/// require (issue #18). Strings pass through verbatim; anything else
95/// (Object / Array / Number / Bool / Null) is serde-stringified. This
96/// is the single canonical rendering — the coercion that used to sit
97/// inside `EngineDispatcher::dispatch` moved here and is invoked only
98/// at consumer boundaries: `WorkerPayload.prompt` (HTTP
99/// `/v1/worker/prompt`), `WorkerInvocation.prompt` (in-process
100/// spawners), the subprocess spawner's directive arg/stdin, and the
101/// WS Spawn frame text render (`operator_ws::session`). Everything
102/// upstream (Blueprint dispatch → engine state → `fetch_prompt` →
103/// `Operator::execute`) keeps the `Value` end-to-end.
104pub(crate) fn render_directive_to_string(v: &Value) -> String {
105    match v {
106        Value::String(s) => s.clone(),
107        other => other.to_string(),
108    }
109}
110
111/// Renders a [`crate::worker::output::ContentRef`] down to the `Value` shape
112/// the BP-chain / `DispatchOutcome` consume. `Inline` passes its `value`
113/// through verbatim; `FileRef` is stringified into the same
114/// `{"file_ref", "mime", "size_hint"}` shape `materialize_final_submission`
115/// uses for its own file-materialize projection — one canonical
116/// stringification, not two independently-maintained copies (GH #36 ST1:
117/// shared by both the `Final`-pull and the `Artifact`-parts fold in
118/// [`Engine::dispatch_attempt_with`]'s doc).
119fn content_ref_to_value(content: crate::worker::output::ContentRef) -> Value {
120    match content {
121        crate::worker::output::ContentRef::Inline { value } => value,
122        crate::worker::output::ContentRef::FileRef {
123            path,
124            mime,
125            size_hint,
126        } => serde_json::json!({
127            "file_ref": path.to_string_lossy(),
128            "mime": mime,
129            "size_hint": size_hint,
130        }),
131    }
132}
133
134/// GH #51 — reduces a [`content_ref_to_value`] result down to the `String`
135/// shape the completion-time verdict-contract check compares against a
136/// declared `VerdictContract.values` token set. A `Value::String` unwraps
137/// to its raw contents (no surrounding JSON quotes) — this mirrors the
138/// pre-GH-#51 `check_verdict_contract` (`mlua-swarm-server`'s
139/// `worker.rs`), which always compared the raw submitted body string
140/// directly, never a JSON-stringified copy. Any OTHER `Value` shape
141/// (`Number` / `Object` / `Array` / `Bool` / `Null` — i.e. a `channel:
142/// "body"` contract whose completing value is not a string at all, or a
143/// `FileRef` content whose `content_ref_to_value` projection is an
144/// object) falls back to `Value::to_string()`'s JSON-encoded form: it can
145/// never collide with a plain declared token like `"PASS"`, so it
146/// naturally fails membership — consistent with the "non-string values
147/// under a body contract are violations" rule (issue #51's Proposal).
148fn content_ref_to_comparable_string(content: crate::worker::output::ContentRef) -> String {
149    let value = content_ref_to_value(content);
150    match value {
151        Value::String(s) => s,
152        other => other.to_string(),
153    }
154}
155
156/// [`Engine::dispatch_attempt_with`]'s Final-pull assembly (GH #36 ST1:
157/// named multi-part worker output), factored out as a pure function of the
158/// output-event tail so it is unit-testable without a live `Engine` /
159/// spawner.
160///
161/// Finds the LAST `Final` event in `tail` (mirrors the pre-GH-#36 pull:
162/// "last Final wins" if more than one was ever appended) and folds every
163/// `Artifact` event in the SAME tail WHOSE NAME APPEARS IN `staged_names`
164/// into a `"parts"` object keyed by `Artifact.name` — walked in tail (=
165/// event-append) order, so a name staged more than once within the attempt
166/// is last-write-wins (`Map` insert semantics, not an accumulating list;
167/// `Engine::stage_worker_artifact_trusted`'s doc). `staged_names` is the
168/// WORKER's own opt-in allowlist (`EngineState.worker_artifact_names`'s
169/// doc) — an `Artifact` on the tail whose name is NOT in `staged_names`
170/// (e.g. `AfterRunAuditMiddleware`'s `"audit:<step_ref>"` sidecar finding)
171/// is left alone, exactly as before GH #36; this is what keeps an audited
172/// step's BP-chain value byte-identical when the worker itself never
173/// staged a part.
174///
175/// At least one matching part: the returned value is `{"out": <final
176/// value>, "parts": {<name>: <value>, ...}}`. Zero matching parts: the
177/// returned value is the plain final value, unchanged from the pre-GH-#36
178/// shape — this is the back-compat guarantee, not an incidental default.
179///
180/// `None` when `tail` carries no `Final` at all (the caller's pre-existing
181/// "no Final in output_tail" error path).
182fn fold_final_and_parts(
183    tail: &[crate::worker::output::OutputEvent],
184    staged_names: &[String],
185) -> Option<(Value, bool)> {
186    let (final_content, ok) = tail.iter().rev().find_map(|ev| match ev {
187        crate::worker::output::OutputEvent::Final { content, ok } => Some((content.clone(), *ok)),
188        _ => None,
189    })?;
190    let final_value = content_ref_to_value(final_content);
191
192    let mut parts = serde_json::Map::new();
193    for ev in tail {
194        if let crate::worker::output::OutputEvent::Artifact { name, content } = ev {
195            if staged_names.iter().any(|staged| staged == name) {
196                parts.insert(name.clone(), content_ref_to_value(content.clone()));
197            }
198        }
199    }
200
201    let value = if parts.is_empty() {
202        final_value
203    } else {
204        serde_json::json!({ "out": final_value, "parts": Value::Object(parts) })
205    };
206    Some((value, ok))
207}
208
209impl Engine {
210    /// Backwards-compatible constructor that starts the engine without a
211    /// layer registry, preserving the signature already used by ~88
212    /// existing call sites. Use this when automatic middleware wrapping
213    /// at bind time is not needed. Callers such as `mlua-swarm-server` go through
214    /// `new_with_layers(cfg, registry)` to enable the hint-resolution path.
215    pub fn new(cfg: EngineCfg) -> Self {
216        Self::new_with_layers(cfg, crate::middleware::LayerRegistry::new())
217    }
218
219    /// Construct an `Engine` with an explicit `LayerRegistry`, enabling
220    /// hint-resolution: `spawner_hints.layers` declared on a `Blueprint`
221    /// are resolved against this registry when the spawner stack is bound
222    /// at `service::linker::link` time.
223    pub fn new_with_layers(
224        cfg: EngineCfg,
225        layer_registry: crate::middleware::LayerRegistry,
226    ) -> Self {
227        let (event_tx, _) = broadcast::channel(256);
228        let signer = TokenSigner::new(&cfg.token_secret);
229        Self {
230            inner: Arc::new(EngineInner {
231                state: Mutex::new(EngineState::new()),
232                cfg,
233                signer,
234                gate: default_role_verb_table(),
235                event_tx,
236                senior_bridges: tokio::sync::RwLock::new(HashMap::new()),
237                spawn_hooks: tokio::sync::RwLock::new(HashMap::new()),
238                operators: tokio::sync::RwLock::new(HashMap::new()),
239                layer_registry,
240                data_store: std::sync::RwLock::new(None),
241                verdict_contracts: std::sync::RwLock::new(HashMap::new()),
242            }),
243        }
244    }
245
246    /// Rebuild this `Engine` with a different `RoleVerbGate`. The gate is
247    /// treated as fixed-at-build-time, so this constructs a fresh
248    /// `EngineInner` (fresh empty `EngineState`) rather than mutating in
249    /// place — mainly a testing convenience for swapping gate rules.
250    pub fn with_gate(self, gate: RoleVerbGate) -> Self {
251        // The gate is fixed at build time — the intent is to build a fresh
252        // instance rather than mutating in place. As a testing convenience we
253        // do allow swapping the inner Arc. Simpler form: just rebuild
254        // Arc<EngineInner>.
255        let inner = Arc::new(EngineInner {
256            state: Mutex::new(EngineState::new()),
257            cfg: self.inner.cfg.clone(),
258            signer: self.inner.signer.clone(),
259            gate,
260            event_tx: self.inner.event_tx.clone(),
261            senior_bridges: tokio::sync::RwLock::new(HashMap::new()),
262            spawn_hooks: tokio::sync::RwLock::new(HashMap::new()),
263            operators: tokio::sync::RwLock::new(HashMap::new()),
264            layer_registry: self.inner.layer_registry.clone(),
265            data_store: std::sync::RwLock::new(None),
266            verdict_contracts: std::sync::RwLock::new(HashMap::new()),
267        });
268        Self { inner }
269    }
270
271    // ═══════════════════════════════════════════════════════════════════════
272    // Accessors. Production code drives execution through compile +
273    // `service::linker::link` + `dispatch_attempt_with(spawner)` inside
274    // `TaskLaunchService`; `Engine` itself is a pure execution surface — it
275    // does not own a BlueprintStore / EnhanceAdapter / Compiler, nor a
276    // global spawner (the spawner is carried per-request, never stashed on
277    // the engine).
278    // ═══════════════════════════════════════════════════════════════════════
279
280    /// Access the `EngineCfg` this engine was built with.
281    pub fn cfg(&self) -> &EngineCfg {
282        &self.inner.cfg
283    }
284
285    /// Expose the internal `LayerRegistry` — used when deriving a
286    /// sub-engine that needs the same registry re-injected. The
287    /// per-request sub-engine in `mlua-swarm-server` reads the parent engine's
288    /// registry through this accessor and passes it to
289    /// `Engine::new_with_layers(cfg, parent.layer_registry().clone())`.
290    pub fn layer_registry(&self) -> &crate::middleware::LayerRegistry {
291        &self.inner.layer_registry
292    }
293
294    /// Access the `TokenSigner` used to mint/verify `CapToken`s.
295    pub fn signer(&self) -> &TokenSigner {
296        &self.inner.signer
297    }
298
299    /// Clone a handle to the process-wide `Event` broadcast sender. Prefer
300    /// `subscribe` for a ready-to-use receiver.
301    pub fn event_tx(&self) -> broadcast::Sender<Event> {
302        self.inner.event_tx.clone()
303    }
304
305    /// Subscribe to the engine's `Event` broadcast stream.
306    pub fn subscribe(&self) -> EventStream {
307        self.inner.event_tx.subscribe()
308    }
309
310    /// Wires the Data-plane [`crate::store::output::OutputStore`] backend
311    /// used by `submit_output` / `submit_worker_result_trusted`'s
312    /// submit-time projection sink (subtask-4 / ST2 rework — see
313    /// `submit_output`'s doc). Synchronous (a plain `std::sync::RwLock`
314    /// write) so a caller can wire it up at boot from a non-`async`
315    /// context (`mlua-swarm-server`'s router builder passes the same
316    /// `Arc` it hands to its `AppState.data_store`, so `POST
317    /// /v1/data/emit` and every worker's ordinary `/v1/worker/submit` land
318    /// in the one store). Calling this more than once replaces the
319    /// previous backend; not calling it at all (the default) preserves
320    /// pre-subtask-4 behavior exactly — `submit_output` only touches the
321    /// Domain-plane `EngineState.output_store` HashMap.
322    pub fn set_output_store(&self, store: Arc<dyn crate::store::output::OutputStore>) {
323        let mut guard = self
324            .inner
325            .data_store
326            .write()
327            .unwrap_or_else(|poisoned| poisoned.into_inner());
328        *guard = Some(store);
329    }
330
331    /// Clones the currently-wired Data-plane store handle, if any. Kept
332    /// private and side-effect-free (no lock held past this call) —
333    /// callers (`materialize_final_submission`) do their actual `.append`
334    /// work outside of any lock.
335    fn output_store_backend(&self) -> Option<Arc<dyn crate::store::output::OutputStore>> {
336        self.inner
337            .data_store
338            .read()
339            .unwrap_or_else(|poisoned| poisoned.into_inner())
340            .clone()
341    }
342
343    /// GH #50 (Subtask 2): merges `contracts` (agent name → declared
344    /// [`mlua_swarm_schema::VerdictContract`]) into the engine's runtime
345    /// verdict-contract registry, later resolved per-task by
346    /// [`Self::verdict_contract_for_task`]. Same sync-write idiom as
347    /// [`Self::set_output_store`] — a plain `std::sync::RwLock` write, so
348    /// this can be called from a non-`async` context. Production call
349    /// site: `TaskLaunchService::launch`, immediately after a successful
350    /// `Compiler::compile`, passing `compiled.router.verdict_contracts.clone()`.
351    ///
352    /// # Overwrite semantics (explicit — read before adding a second call site)
353    ///
354    /// The registry is a single flat `HashMap` **keyed by agent name only**
355    /// (`String`), with process-wide (not per-task, not per-Blueprint,
356    /// not per-launch) scope. Registration is additive via
357    /// `HashMap::extend`: an entry for an agent name NOT already present is
358    /// added; an entry for an agent name ALREADY present is REPLACED
359    /// (last write wins) by the incoming one. Concretely: launching a
360    /// second Blueprint that also declares a `verdict` contract for an
361    /// agent named `"gate"` OVERWRITES whatever contract a first, still
362    /// in-flight, launch registered for an agent of that same name — even
363    /// if the two Blueprints intend it as two semantically different
364    /// agents that merely share a name, and even while the first launch's
365    /// tasks are still running. This is a **known limitation** of the v1
366    /// design; a per-task (or per-`RunId` / per-Blueprint) scoped registry
367    /// is a possible follow-up if two concurrently in-flight Blueprints
368    /// declaring conflicting contracts under the same agent name turns out
369    /// to matter in practice. Calling this with an empty map (or not at
370    /// all — the default) is a no-op, preserving pre-GH-#50 behavior
371    /// exactly (opt-in).
372    pub fn register_verdict_contracts(
373        &self,
374        contracts: HashMap<String, mlua_swarm_schema::VerdictContract>,
375    ) {
376        let mut guard = self
377            .inner
378            .verdict_contracts
379            .write()
380            .unwrap_or_else(|poisoned| poisoned.into_inner());
381        guard.extend(contracts);
382    }
383
384    /// GH #50 (Subtask 2): the declared
385    /// [`mlua_swarm_schema::VerdictContract`] for the agent currently
386    /// running `task_id`, if any. Resolves `task_id` → `TaskState.spec.agent`
387    /// (via `EngineState.tasks`, the same lookup [`Self::task_attempt`]
388    /// performs) and looks that agent name up in the registry
389    /// [`Self::register_verdict_contracts`] populates.
390    ///
391    /// `None` in both of these cases — deliberately collapsed to the same
392    /// value, mirroring [`Self::agent_context_for`]'s `Result`-into-`Option`
393    /// pattern (`.ok().flatten()`; a lookup failure here is never itself an
394    /// error worth surfacing to a caller):
395    /// - `task_id` is unknown (no `TaskState` for it).
396    /// - `task_id` resolves to a known agent, but that agent declared no
397    ///   `verdict` contract (the opt-in default).
398    ///
399    /// Callers (`mlua-swarm-server`'s `worker_submit` / `worker_artifact`)
400    /// treat every `None` identically: skip the submit-time verdict gate
401    /// entirely, preserving pre-GH-#50 behavior byte-for-byte.
402    pub async fn verdict_contract_for_task(
403        &self,
404        task_id: &StepId,
405    ) -> Option<mlua_swarm_schema::VerdictContract> {
406        let tid = task_id.clone();
407        let agent = self
408            .with_state("verdict_contract_for_task", move |s| {
409                s.tasks.get(&tid).map(|t| t.spec.agent.clone())
410            })
411            .await
412            .ok()
413            .flatten()?;
414        self.inner
415            .verdict_contracts
416            .read()
417            .unwrap_or_else(|poisoned| poisoned.into_inner())
418            .get(&agent)
419            .cloned()
420    }
421
422    /// GH #51 — the value of the LAST staged `"verdict"` `Artifact` for
423    /// `(task_id, attempt)`, if any. Mirrors [`fold_final_and_parts`]'s
424    /// reverse-scan-of-`output_tail` pattern (last-write-wins per name,
425    /// same as that fold and [`Self::stage_worker_artifact_trusted`]'s
426    /// doc), narrowed to the single literal artifact name
427    /// `channel: "part"` contracts address (Pattern B — see
428    /// `blueprint-authoring.md`'s "Returning verdicts to drive BP flow").
429    ///
430    /// Infallible accessor: `None` is the normal "nothing staged yet"
431    /// case, not an error — the caller
432    /// ([`Self::verdict_contract_completion_check`]) is what converts
433    /// `None` into `Err(EngineError::VerdictPartMissing)`.
434    pub(crate) async fn staged_verdict_value_for(
435        &self,
436        task_id: &StepId,
437        attempt: u32,
438    ) -> Option<String> {
439        let tail = self.output_tail(task_id, attempt).await;
440        tail.iter().rev().find_map(|ev| match ev {
441            crate::worker::output::OutputEvent::Artifact { name, content } if name == "verdict" => {
442                Some(content_ref_to_comparable_string(content.clone()))
443            }
444            _ => None,
445        })
446    }
447
448    /// GH #51 — the single completion-time verdict-contract choke point,
449    /// embedded inside BOTH [`Self::submit_worker_result_trusted`] and
450    /// [`Self::submit_output`] (the two engine-side writes every HTTP/WS
451    /// completion route ultimately passes through). Not duplicated per
452    /// route handler — a future 4th completion route is gated for free
453    /// as long as it funnels through one of those two functions.
454    ///
455    /// `ok=false` is exempt on every route (this single early-return IS
456    /// the exemption, reused identically by both embedding sites — see
457    /// issue #51's "ok=false completions are exempt" acceptance
458    /// criterion). An agent with no declared contract, or a contract for
459    /// the OTHER channel, is untouched (`Ok(())`) — same opt-in,
460    /// byte-for-byte-preserving posture as
461    /// [`Self::verdict_contract_for_task`]'s doc.
462    ///
463    /// - `channel: "body"` — `value` (the completing `Final`'s content,
464    ///   already reduced to a comparable string by the caller via
465    ///   [`content_ref_to_comparable_string`]) must be a member of
466    ///   `contract.values`.
467    /// - `channel: "part"` — [`Self::staged_verdict_value_for`] must find
468    ///   a staged `"verdict"` artifact for this attempt (presence,
469    ///   defense in depth over the staging-time membership check) AND its
470    ///   value must be a member of `contract.values`.
471    async fn verdict_contract_completion_check(
472        &self,
473        task_id: &StepId,
474        attempt: u32,
475        ok: bool,
476        value: &str,
477    ) -> Result<(), EngineError> {
478        if !ok {
479            return Ok(());
480        }
481        let Some(contract) = self.verdict_contract_for_task(task_id).await else {
482            return Ok(());
483        };
484        match contract.channel {
485            mlua_swarm_schema::VerdictChannel::Body => {
486                if contract.values.iter().any(|v| v == value) {
487                    Ok(())
488                } else {
489                    Err(EngineError::VerdictValueRejected {
490                        value: value.to_string(),
491                        allowed: contract.values.clone(),
492                    })
493                }
494            }
495            mlua_swarm_schema::VerdictChannel::Part => {
496                match self.staged_verdict_value_for(task_id, attempt).await {
497                    None => Err(EngineError::VerdictPartMissing {
498                        allowed: contract.values.clone(),
499                    }),
500                    Some(staged) if contract.values.iter().any(|v| v == &staged) => Ok(()),
501                    Some(staged) => Err(EngineError::VerdictValueRejected {
502                        value: staged,
503                        allowed: contract.values.clone(),
504                    }),
505                }
506            }
507        }
508    }
509
510    // ═══════════════════════════════════════════════════════════════════════
511    // §7 with_state — single Mutex + R1-R4 (try_lock + bounded retry + max-hold panic)
512    // ═══════════════════════════════════════════════════════════════════════
513
514    /// The closure is a **sync** `FnOnce` — you cannot pass an async
515    /// closure, which enforces R3 at the type level. Exceeding `max_hold`
516    /// panics so that R4 violations surface immediately.
517    pub async fn with_state<F, R>(&self, op: &'static str, f: F) -> Result<R, EngineError>
518    where
519        F: FnOnce(&mut EngineState) -> R,
520    {
521        let cfg = &self.inner.cfg;
522
523        // R2: try_lock + bounded retry
524        let mut guard_opt = None;
525        for attempt in 0..=cfg.max_retry {
526            match self.inner.state.try_lock() {
527                Ok(g) => {
528                    guard_opt = Some(g);
529                    break;
530                }
531                Err(_) if cfg.try_only => return Err(EngineError::LockBusy(op)),
532                Err(_) => {
533                    let backoff = cfg.backoff_ms_step * (attempt as u64 + 1);
534                    tokio::time::sleep(Duration::from_millis(backoff)).await;
535                }
536            }
537        }
538        let mut guard = guard_opt.ok_or(EngineError::LockBusyAfterRetry(op))?;
539
540        // R4: max_hold guard
541        let start = Instant::now();
542        let result = f(&mut guard);
543        let elapsed_ms = start.elapsed().as_millis();
544        drop(guard);
545
546        if elapsed_ms > cfg.max_hold_ms {
547            panic!(
548                "Engine.with_state('{op}') held {elapsed_ms}ms > max {}ms — suspected R3 violation (long op inside lock)",
549                cfg.max_hold_ms
550            );
551        }
552        Ok(result)
553    }
554
555    // ═══════════════════════════════════════════════════════════════════════
556    // Token verify (= sig + expire + gate + uses_left)
557    // ═══════════════════════════════════════════════════════════════════════
558
559    /// Four steps: (1) signature verify, (2) expiry check, (3) role × verb
560    /// gate, (4) `uses_left` consume.
561    pub async fn verify_token(&self, token: &CapToken, verb: Verb) -> Result<(), EngineError> {
562        // (1) sig
563        if !self.inner.signer.verify_sig(token) {
564            return Err(EngineError::BadSignature);
565        }
566        // (2) expire
567        if token.is_expired(now_unix()) {
568            return Err(EngineError::TokenExpired);
569        }
570        // (3) role × verb gate
571        if !self.inner.gate.is_allowed(token.role, verb) {
572            return Err(EngineError::RoleViolation {
573                role: token.role,
574                verb,
575            });
576        }
577        // (4) server-side uses_left consume
578        let fp = token.fingerprint();
579        self.with_state("token.consume", move |s| {
580            let rec = s
581                .tokens
582                .get_mut(&fp)
583                .ok_or_else(|| EngineError::TokenNotFound(fp.clone()))?;
584            rec.consume()
585                .map_err(|_: crate::core::state::CapTokenConsumeError| {
586                    EngineError::TokenUsesExhausted
587                })?;
588            Ok::<(), EngineError>(())
589        })
590        .await??;
591        Ok(())
592    }
593
594    /// `verify_token` plus the **task-ownership gate**.
595    ///
596    /// When a Worker-role token calls a state-touch verb (`fetch_prompt` /
597    /// `post_result` / `read_task_state` / `cancel_task` / `poll_task`),
598    /// the gate checks that `CapTokenRecord.task_id` matches the argument
599    /// `task_id`; a mismatch returns `EngineError::TokenTaskMismatch`.
600    /// Operator / Senior / Observer tokens are outside the ownership gate
601    /// and may touch any task.
602    ///
603    /// **Verbs exempt from the gate.** `start_task` and `dispatch_attempt`
604    /// stay outside so recursive swarming keeps working; depth is capped
605    /// by `max_spawn_depth`.
606    pub async fn verify_token_for_task(
607        &self,
608        token: &CapToken,
609        verb: Verb,
610        task_id: &StepId,
611    ) -> Result<(), EngineError> {
612        self.verify_token(token, verb).await?;
613        if token.role != Role::Worker {
614            return Ok(());
615        }
616        let fp = token.fingerprint();
617        let arg_tid = task_id.clone();
618        self.with_state("token.ownership_gate", move |s| {
619            let bound = s.tokens.get(&fp).and_then(|r| r.task_id.as_ref()).cloned();
620            match bound {
621                Some(t) if t == arg_tid => Ok(()),
622                Some(t) => Err(EngineError::TokenTaskMismatch {
623                    bound: t.into_string(),
624                    arg: arg_tid.into_string(),
625                }),
626                None => Err(EngineError::TokenNotFound(fp.clone())),
627            }
628        })
629        .await??;
630        Ok(())
631    }
632
633    /// Resolve the bound `task_id` from a Worker-role token. Used on the
634    /// simple `/v1/worker/submit` endpoint, where the worker POSTs with a
635    /// token but no `task_id`. Returns `Err` if the token role is not
636    /// Worker, or if no bound task is set.
637    pub async fn task_id_from_token(&self, token: &CapToken) -> Result<StepId, EngineError> {
638        if token.role != Role::Worker {
639            return Err(EngineError::RoleViolation {
640                role: token.role,
641                verb: Verb::PostResult,
642            });
643        }
644        let fp = token.fingerprint();
645        self.with_state("task_id_from_token", move |s| {
646            s.tokens
647                .get(&fp)
648                .and_then(|r| r.task_id.as_ref())
649                .cloned()
650                .ok_or_else(|| EngineError::TokenNotFound(fp.clone()))
651        })
652        .await?
653    }
654
655    /// Resolve a short worker handle (`wh-XXXXXXXX`) to the bound
656    /// `task_id`. Used on `/v1/worker/submit` when the Bearer is a short
657    /// handle string rather than a full `CapToken` JSON. A missing entry
658    /// returns `TokenNotFound`, i.e. "the handle is not in the store".
659    pub async fn task_id_from_handle(&self, handle: &str) -> Result<StepId, EngineError> {
660        let h = handle.to_string();
661        self.with_state("task_id_from_handle", move |s| {
662            let fp = s
663                .worker_handles
664                .get(&h)
665                .cloned()
666                .ok_or_else(|| EngineError::TokenNotFound(format!("handle={h}")))?;
667            s.tokens
668                .get(&fp)
669                .and_then(|r| r.task_id.as_ref())
670                .cloned()
671                .ok_or_else(|| EngineError::TokenNotFound(format!("fp={fp}")))
672        })
673        .await?
674    }
675
676    /// Submit a worker result via a short handle. Skips token verification
677    /// and updates `output_tail` `Final` + `task.last_result` directly in
678    /// a thin path. The caller is expected to have already resolved
679    /// `task_id` via `task_id_from_handle` — the handle's presence in
680    /// `worker_handles` means it was minted server-side and is therefore
681    /// trusted.
682    pub async fn submit_worker_result_trusted(
683        &self,
684        task_id: &StepId,
685        attempt: u32,
686        value: Value,
687        ok: bool,
688    ) -> Result<(), EngineError> {
689        // GH #51 — completion-time verdict-contract enforcement, embedded
690        // choke point 1 of 2 (see `Self::verdict_contract_completion_check`'s
691        // doc). This path always submits a `Final` by construction (there
692        // is no other event kind on `/v1/worker/submit`), so the check
693        // always applies — unlike `submit_output` below, no `if let
694        // OutputEvent::Final { .. }` guard is needed here since there is
695        // no other `OutputEvent` variant this function could be asked to
696        // write. Runs BEFORE the `output_tail` write immediately below:
697        // on `Err`, this returns immediately and neither `with_state` call
698        // in this function executes.
699        let comparable_value =
700            content_ref_to_comparable_string(crate::worker::output::ContentRef::Inline {
701                value: value.clone(),
702            });
703        self.verdict_contract_completion_check(task_id, attempt, ok, &comparable_value)
704            .await?;
705        let task_id_for_apply = task_id.clone();
706        let value_for_event = value.clone();
707        self.with_state("submit_worker_result_trusted.output", move |s| {
708            let ev = crate::worker::output::OutputEvent::Final {
709                content: crate::worker::output::ContentRef::Inline {
710                    value: value_for_event,
711                },
712                ok,
713            };
714            s.output_store
715                .entry((task_id_for_apply.clone(), attempt))
716                .or_default()
717                .push(ev.clone());
718            s.push_event(crate::core::state::Event::WorkerOutput {
719                task_id: task_id_for_apply,
720                attempt,
721                event: ev,
722            });
723        })
724        .await?;
725        let task_id_for_result = task_id.clone();
726        let value_for_result = value.clone();
727        self.with_state("submit_worker_result_trusted.last_result", move |s| {
728            if let Some(t) = s.tasks.get_mut(&task_id_for_result) {
729                t.last_result = Some(value_for_result);
730                t.updated_at = now_unix();
731            }
732        })
733        .await?;
734        // subtask-4 / ST2 rework: this path always submits a `Final` (there
735        // is no other event kind on `/v1/worker/submit`), so the
736        // submit-time projection sink always fires — see
737        // `materialize_final_submission`'s doc and `submit_output`'s
738        // Invariants (fail-open, never turns a would-have-succeeded submit
739        // into a failure).
740        let content = crate::worker::output::ContentRef::Inline { value };
741        self.materialize_final_submission(task_id, attempt, &content, ok)
742            .await?;
743        Ok(())
744    }
745
746    /// Stage a named `Artifact` from a worker via a short handle (GH #36
747    /// ST1: named multi-part worker output). Trusted analog of
748    /// [`Self::submit_worker_result_trusted`] for `OutputEvent::Artifact`:
749    /// skips token verification for the same reason (the caller already
750    /// resolved `task_id` via `task_id_from_handle`, so the handle's
751    /// presence in `worker_handles` is itself the trust boundary).
752    ///
753    /// Appends to the same per-`(task_id, attempt)` `output_store` tail
754    /// [`Self::dispatch_attempt_with`]'s Final-pull later folds into
755    /// `{"out": <final>, "parts": {<name>: <value>, ...}}` (see that
756    /// method's doc for the fold semantics — event order, last-write-wins
757    /// per name), AND records `name` in `EngineState.worker_artifact_names`
758    /// — the fold's allowlist of the WORKER's own staged parts, as opposed
759    /// to every `Artifact` that happens to land on the shared tail (e.g. an
760    /// audit sidecar finding; see that field's doc). Also dual-writes to
761    /// the Data-plane `OutputStore` the same way [`Self::submit_output`]'s
762    /// `Artifact` arm does, via [`Self::materialize_artifact_submission`]
763    /// (the artifact's own `name` is its Data-plane key, no
764    /// canonicalization — see that method's doc).
765    pub async fn stage_worker_artifact_trusted(
766        &self,
767        task_id: &StepId,
768        attempt: u32,
769        name: String,
770        value: Value,
771    ) -> Result<(), EngineError> {
772        let content = crate::worker::output::ContentRef::Inline { value };
773        let task_id_for_apply = task_id.clone();
774        let name_for_apply = name.clone();
775        let content_for_apply = content.clone();
776        self.with_state("stage_worker_artifact_trusted.output", move |s| {
777            let ev = crate::worker::output::OutputEvent::Artifact {
778                name: name_for_apply.clone(),
779                content: content_for_apply,
780            };
781            s.output_store
782                .entry((task_id_for_apply.clone(), attempt))
783                .or_default()
784                .push(ev.clone());
785            s.worker_artifact_names
786                .entry((task_id_for_apply.clone(), attempt))
787                .or_default()
788                .push(name_for_apply);
789            s.push_event(crate::core::state::Event::WorkerOutput {
790                task_id: task_id_for_apply,
791                attempt,
792                event: ev,
793            });
794        })
795        .await?;
796        self.materialize_artifact_submission(task_id, attempt, &name, &content)
797            .await?;
798        Ok(())
799    }
800
801    /// GH #36 ST1: the set of `Artifact` names staged for `(task_id,
802    /// attempt)` via [`Self::stage_worker_artifact_trusted`] — see
803    /// `EngineState.worker_artifact_names`'s doc. Used by
804    /// [`Self::dispatch_attempt_with`]'s Final-pull to distinguish a
805    /// worker's own named parts from any other `Artifact` producer on the
806    /// same tail.
807    async fn worker_artifact_names_for(&self, task_id: &StepId, attempt: u32) -> Vec<String> {
808        let key = (task_id.clone(), attempt);
809        self.with_state("worker_artifact_names_for", move |s| {
810            s.worker_artifact_names
811                .get(&key)
812                .cloned()
813                .unwrap_or_default()
814        })
815        .await
816        .unwrap_or_default()
817    }
818
819    /// Mint a short handle and register it in the `worker_handles` map.
820    /// Called immediately after the worker-token mint inside
821    /// `dispatch_attempt_with`, and issues a handle bound to the same
822    /// token fingerprint. Format is `wh-<8 hex chars>` (11 chars total),
823    /// designed to remove the base64 copy-paste failure mode.
824    async fn mint_worker_handle(&self, worker_fp: String) -> Result<String, EngineError> {
825        // The handle is a sole bearer secret on the `/v1/worker/submit`
826        // short-handle path (`submit_worker_result_trusted` skips token
827        // verification), so it must be unguessable — OS RNG, not the
828        // predictable uid counter. 8 hex chars (~4B entropy) keeps the
829        // documented `wh-<8 hex>` wire shape; collision between live
830        // handles is negligible at in-process handle counts.
831        let short = crate::types::secure_hex(4);
832        let handle = format!("wh-{short}");
833        let h = handle.clone();
834        self.with_state("mint_worker_handle", move |s| {
835            s.worker_handles.insert(h, worker_fp);
836        })
837        .await?;
838        Ok(handle)
839    }
840
841    // ═══════════════════════════════════════════════════════════════════════
842    // Session API
843    // ═══════════════════════════════════════════════════════════════════════
844
845    /// Attach a new session with default `OperatorInfo` (`Automate`, no
846    /// bridges/hooks). Shorthand for `attach_with(.., OperatorInfo::default())`.
847    pub async fn attach(
848        &self,
849        operator_id: impl Into<String>,
850        role: Role,
851        ttl: Duration,
852    ) -> Result<CapToken, EngineError> {
853        self.attach_with(
854            operator_id,
855            role,
856            ttl,
857            crate::core::ctx::OperatorInfo::default(),
858        )
859        .await
860    }
861
862    // ═══════════════════════════════════════════════════════════════════════
863    // BridgeRegistry API.
864    // ═══════════════════════════════════════════════════════════════════════
865
866    /// Register a `SeniorBridge` under a name. An existing entry with the
867    /// same name is overwritten. On the persisted-session reattach path,
868    /// the caller re-registers under the same ID beforehand and the
869    /// bridge becomes effective again.
870    pub async fn register_senior_bridge(
871        &self,
872        id: impl Into<String>,
873        bridge: Arc<dyn SeniorBridge>,
874    ) {
875        self.inner
876            .senior_bridges
877            .write()
878            .await
879            .insert(id.into(), bridge);
880    }
881
882    /// Register a `SpawnHook` under a name. An existing entry with the
883    /// same name is overwritten.
884    pub async fn register_spawn_hook(&self, id: impl Into<String>, hook: Arc<dyn SpawnHook>) {
885        self.inner.spawn_hooks.write().await.insert(id.into(), hook);
886    }
887
888    /// Register an `Operator` (a spawn-body backend) under a name. An
889    /// existing entry with the same name is overwritten.
890    /// `OperatorDelegateMiddleware` looks this up via `ctx` and, when
891    /// `kind = MainAi` / `Composite`, bypasses `inner.spawn` and calls
892    /// `operator.execute` instead.
893    pub async fn register_operator(
894        &self,
895        id: impl Into<String>,
896        operator: Arc<dyn crate::operator::Operator>,
897    ) {
898        self.inner
899            .operators
900            .write()
901            .await
902            .insert(id.into(), operator);
903    }
904
905    /// Unregister a `SeniorBridge` by name (e.g. on WebSocket disconnect
906    /// or explicit teardown). A missing ID is a no-op.
907    pub async fn unregister_senior_bridge(&self, id: &str) {
908        self.inner.senior_bridges.write().await.remove(id);
909    }
910
911    /// Unregister a `SpawnHook` by name. A missing ID is a no-op.
912    pub async fn unregister_spawn_hook(&self, id: &str) {
913        self.inner.spawn_hooks.write().await.remove(id);
914    }
915
916    /// Unregister an `Operator` backend by name. A missing ID is a no-op.
917    pub async fn unregister_operator(&self, id: &str) {
918        self.inner.operators.write().await.remove(id);
919    }
920
921    /// Snapshot the list of registered `SpawnHook` IDs (for test
922    /// observation and debugging).
923    pub async fn list_spawn_hook_ids(&self) -> Vec<String> {
924        self.inner
925            .spawn_hooks
926            .read()
927            .await
928            .keys()
929            .cloned()
930            .collect()
931    }
932
933    /// Snapshot the list of registered `SeniorBridge` IDs.
934    pub async fn list_senior_bridge_ids(&self) -> Vec<String> {
935        self.inner
936            .senior_bridges
937            .read()
938            .await
939            .keys()
940            .cloned()
941            .collect()
942    }
943
944    /// Snapshot the list of registered `Operator` IDs.
945    pub async fn list_operator_ids(&self) -> Vec<String> {
946        self.inner.operators.read().await.keys().cloned().collect()
947    }
948
949    /// Attach specifying IDs directly. The caller is expected to have
950    /// pre-registered them via `register_senior_bridge` /
951    /// `register_spawn_hook` / `register_operator`. This is the canonical
952    /// path when persistence is in play.
953    ///
954    /// `kind` is the "Runtime Global" tier of the `OperatorKind` cascade
955    /// (stored verbatim on `OperatorSession.operator_kind`): `Some(_)` is
956    /// an explicit request (including `Some(OperatorKind::Automate)`) that
957    /// outranks the BP-level tiers; `None` leaves it unspecified so the
958    /// BP-level tiers / final default decide. See
959    /// `crate::core::ctx::collapse_operator_kind`.
960    #[allow(clippy::too_many_arguments)]
961    pub async fn attach_with_ids(
962        &self,
963        operator_id: impl Into<String>,
964        role: Role,
965        ttl: Duration,
966        kind: Option<OperatorKind>,
967        bridge_id: Option<String>,
968        hook_id: Option<String>,
969        operator_backend_id: Option<String>,
970        operator_kind_overrides: HashMap<String, OperatorKind>,
971        bp_agent_kinds: HashMap<String, OperatorKind>,
972        bp_global_kind: Option<OperatorKind>,
973    ) -> Result<CapToken, EngineError> {
974        let operator_id = operator_id.into();
975        let token = self
976            .inner
977            .signer
978            .session(operator_id.clone(), role, vec!["*".into()], ttl);
979        let session_id = SessionId::new();
980        let fp = token.fingerprint();
981        let now = now_unix();
982        let token_for_store = token.clone();
983
984        self.with_state("attach_with_ids", |s| {
985            s.tokens
986                .insert(fp.clone(), CapTokenRecord::from_token(token_for_store));
987            s.sessions.insert(
988                session_id.clone(),
989                OperatorSession {
990                    id: session_id.clone(),
991                    operator_id: operator_id.clone(),
992                    role,
993                    attached_at: now,
994                    last_seen: now,
995                    attached: true,
996                    owned_task_ids: Vec::new(),
997                    token_fp: fp.clone(),
998                    operator_kind: kind,
999                    runtime_agent_kinds: operator_kind_overrides,
1000                    bp_agent_kinds,
1001                    bp_global_kind,
1002                    bridge_id,
1003                    hook_id,
1004                    operator_backend_id,
1005                },
1006            );
1007            s.push_event(Event::SessionAttached {
1008                session_id: session_id.clone(),
1009                role,
1010            });
1011        })
1012        .await?;
1013
1014        let _ = self
1015            .inner
1016            .event_tx
1017            .send(Event::SessionAttached { session_id, role });
1018        Ok(token)
1019    }
1020
1021    /// Build an `OperatorInfo` by looking up the session's registered IDs
1022    /// on the `BridgeRegistry`, plus resolving the 4-tier `OperatorKind`
1023    /// cascade for `agent_name` via `crate::core::ctx::collapse_operator_kind`.
1024    /// Used when `dispatch_attempt` injects `Ctx`. An unresolved ID
1025    /// (nothing registered) is silently `None` — the bridge / hook simply
1026    /// does not fire and the default behaviour applies.
1027    async fn resolve_operator_info(
1028        &self,
1029        session: &OperatorSession,
1030        agent_name: &str,
1031    ) -> OperatorInfo {
1032        let senior_bridge = if let Some(id) = &session.bridge_id {
1033            self.inner.senior_bridges.read().await.get(id).cloned()
1034        } else {
1035            None
1036        };
1037        let spawn_hook = if let Some(id) = &session.hook_id {
1038            self.inner.spawn_hooks.read().await.get(id).cloned()
1039        } else {
1040            None
1041        };
1042        let operator = if let Some(id) = &session.operator_backend_id {
1043            self.inner.operators.read().await.get(id).cloned()
1044        } else {
1045            None
1046        };
1047        let runtime_agent = session.runtime_agent_kinds.get(agent_name).copied();
1048        // "Runtime Global" tier: `Some(_)` is always an explicit request
1049        // (see the field doc on `OperatorSession.operator_kind`).
1050        let runtime_global = session.operator_kind;
1051        let bp_agent = session.bp_agent_kinds.get(agent_name).copied();
1052        let bp_global = session.bp_global_kind;
1053        let kind = crate::core::ctx::collapse_operator_kind(
1054            runtime_agent,
1055            runtime_global,
1056            bp_agent,
1057            bp_global,
1058        );
1059        OperatorInfo {
1060            kind,
1061            id: session.operator_id.clone(),
1062            senior_bridge,
1063            spawn_hook,
1064            operator,
1065        }
1066    }
1067
1068    /// Convenience attach that takes an `OperatorInfo` (three
1069    /// `Arc<dyn ...>` fields plus `kind`) **inline**.
1070    ///
1071    /// # Pipeline
1072    ///
1073    /// Each `Arc<dyn ...>` is auto-registered on the engine's registry
1074    /// under a synthetic ID (`br-<hex>` / `hk-<hex>` / `ob-<hex>`), and
1075    /// the session stores that synthetic ID. Subsequent `dispatch_attempt`
1076    /// calls rebuild the `Arc`s from those IDs via
1077    /// `resolve_operator_info`, and the three middlewares fire as usual.
1078    ///
1079    /// # ⚠ Non-persisted sessions only
1080    ///
1081    /// Because this API takes inline `Arc`s, the reattach path after
1082    /// session persistence cannot rebuild them — the synthetic IDs are
1083    /// not present in a freshly started process's registry. If you need
1084    /// persistence, use [`Self::attach_with_ids`] with `register_*` calls
1085    /// beforehand to go through **named IDs** instead.
1086    ///
1087    /// Handy for tests and short-lived in-process sessions. Production
1088    /// WebSocket callbacks and the like should prefer `attach_with_ids`
1089    /// as the canonical path.
1090    pub async fn attach_with(
1091        &self,
1092        operator_id: impl Into<String>,
1093        role: Role,
1094        ttl: Duration,
1095        operator_info: crate::core::ctx::OperatorInfo,
1096    ) -> Result<CapToken, EngineError> {
1097        let operator_id = operator_id.into();
1098        // The caller always hands in a fully-formed `OperatorInfo`
1099        // (including its `kind`), so it is stored as an explicit "Runtime
1100        // Global" tier request (`Some(kind)`) — this path never persists
1101        // BP-level tiers (both stay empty below), so `Some(kind)` resolves
1102        // to the same `kind` at dispatch either way; see
1103        // `OperatorSession.operator_kind` doc.
1104        let kind = operator_info.kind;
1105        // BridgeRegistry auto-register: when the caller hands in an
1106        // `Arc<dyn>` directly, register it under a synthesised ID (the inline
1107        // path aware of persistence). Callers who want to pre-register with a
1108        // named ID should use `register_senior_bridge` / `register_spawn_hook`
1109        // + `attach_with_ids`.
1110        let bridge_id = if let Some(bridge) = operator_info.senior_bridge.clone() {
1111            let id = format!("br-{}", crate::types::uid_hex(8));
1112            self.inner
1113                .senior_bridges
1114                .write()
1115                .await
1116                .insert(id.clone(), bridge);
1117            Some(id)
1118        } else {
1119            None
1120        };
1121        let hook_id = if let Some(hook) = operator_info.spawn_hook.clone() {
1122            let id = format!("hk-{}", crate::types::uid_hex(8));
1123            self.inner
1124                .spawn_hooks
1125                .write()
1126                .await
1127                .insert(id.clone(), hook);
1128            Some(id)
1129        } else {
1130            None
1131        };
1132        let operator_backend_id = if let Some(operator) = operator_info.operator.clone() {
1133            // `ob-` = operator-backend registry id. Renamed from `op-` in the
1134            // issue #11 prefix reconciliation: `op-` used to collide with the
1135            // WS operator sid shape (now unified into `S-<hex>` anyway), and a
1136            // shared prefix across two unrelated registries made log filtering
1137            // by prefix silently ambiguous.
1138            let id = format!("ob-{}", crate::types::uid_hex(8));
1139            self.inner
1140                .operators
1141                .write()
1142                .await
1143                .insert(id.clone(), operator);
1144            Some(id)
1145        } else {
1146            None
1147        };
1148
1149        let token = self
1150            .inner
1151            .signer
1152            .session(operator_id.clone(), role, vec!["*".into()], ttl);
1153        let session_id = SessionId::new();
1154        let fp = token.fingerprint();
1155        let now = now_unix();
1156        let token_for_store = token.clone();
1157
1158        self.with_state("attach_with", |s| {
1159            s.tokens
1160                .insert(fp.clone(), CapTokenRecord::from_token(token_for_store));
1161            s.sessions.insert(
1162                session_id.clone(),
1163                OperatorSession {
1164                    id: session_id.clone(),
1165                    operator_id,
1166                    role,
1167                    attached_at: now,
1168                    last_seen: now,
1169                    attached: true,
1170                    owned_task_ids: Vec::new(),
1171                    token_fp: fp.clone(),
1172                    operator_kind: Some(kind),
1173                    runtime_agent_kinds: HashMap::new(),
1174                    bp_agent_kinds: HashMap::new(),
1175                    bp_global_kind: None,
1176                    bridge_id,
1177                    hook_id,
1178                    operator_backend_id,
1179                },
1180            );
1181            s.push_event(Event::SessionAttached {
1182                session_id: session_id.clone(),
1183                role,
1184            });
1185        })
1186        .await?;
1187
1188        let _ = self
1189            .inner
1190            .event_tx
1191            .send(Event::SessionAttached { session_id, role });
1192        Ok(token)
1193    }
1194
1195    /// Mark the session bound to `token` as detached (`attached = false`).
1196    /// Tasks are left in place — a later `attach`/`attach_with_ids` call
1197    /// carrying the same registered bridge/hook IDs can pick them back up.
1198    pub async fn detach(&self, token: &CapToken) -> Result<(), EngineError> {
1199        self.verify_token(token, Verb::DetachSession).await?;
1200        let fp = token.fingerprint();
1201        self.with_state("detach", move |s| {
1202            let sid = s
1203                .sessions
1204                .iter()
1205                .find(|(_, sess)| sess.token_fp == fp)
1206                .map(|(id, _)| id.clone());
1207            if let Some(sid) = sid {
1208                if let Some(sess) = s.sessions.get_mut(&sid) {
1209                    sess.attached = false;
1210                }
1211                s.push_event(Event::SessionDetached {
1212                    session_id: sid.clone(),
1213                });
1214                let _ = sid;
1215            }
1216        })
1217        .await?;
1218        Ok(())
1219    }
1220
1221    /// Refresh the session's `last_seen` timestamp and mark it `attached`.
1222    /// Called periodically by an attached client to avoid being flipped to
1223    /// detached by `start_detach_loop`.
1224    pub async fn heartbeat(&self, token: &CapToken) -> Result<(), EngineError> {
1225        self.verify_token(token, Verb::Heartbeat).await?;
1226        let now = now_unix();
1227        let fp = token.fingerprint();
1228        self.with_state("heartbeat", move |s| {
1229            if let Some(sess) = s.sessions.values_mut().find(|sess| sess.token_fp == fp) {
1230                sess.last_seen = now;
1231                sess.attached = true;
1232            }
1233        })
1234        .await?;
1235        Ok(())
1236    }
1237
1238    // ═══════════════════════════════════════════════════════════════════════
1239    // Task lifecycle
1240    // ═══════════════════════════════════════════════════════════════════════
1241
1242    /// Create a new `TaskState` from `spec` and register its initial
1243    /// prompt. When the calling token is a Worker (i.e. this is a
1244    /// recursive spawn), the new task inherits `parent.spawn_depth + 1`
1245    /// and is rejected with `SpawnDepthExceeded` once `max_spawn_depth` is
1246    /// hit; an Operator-issued call starts at depth 0.
1247    pub async fn start_task(
1248        &self,
1249        token: &CapToken,
1250        spec: TaskSpec,
1251    ) -> Result<StepId, EngineError> {
1252        self.verify_token(token, Verb::StartTask).await?;
1253        let task_id = StepId::new();
1254        let initial_directive = spec.initial_directive.clone();
1255        let task_id_clone = task_id.clone();
1256        let fp = token.fingerprint();
1257        let max_depth = self.inner.cfg.max_spawn_depth;
1258        self.with_state("start_task", move |s| {
1259            // Recursive swarm depth gate (recursion guard):
1260            // Worker tokens carry CapTokenRecord.parent_task_id. Give the
1261            // child parent's spawn_depth + 1; if it exceeds `max`, raise an
1262            // error. Operator tokens (parent_task_id=None) start at depth 0.
1263            let parent_depth_opt = s
1264                .tokens
1265                .get(&fp)
1266                .and_then(|rec| rec.task_id.as_ref())
1267                .and_then(|tid| s.tasks.get(tid))
1268                .map(|t| t.spawn_depth);
1269            let depth = match parent_depth_opt {
1270                Some(d) => {
1271                    if d + 1 >= max_depth {
1272                        return Err(EngineError::SpawnDepthExceeded {
1273                            current: d + 1,
1274                            max: max_depth,
1275                        });
1276                    }
1277                    d + 1
1278                }
1279                None => 0,
1280            };
1281
1282            let mut task = TaskState::new(task_id_clone.clone(), spec);
1283            task.spawn_depth = depth;
1284            s.tasks.insert(task_id_clone.clone(), task);
1285            s.prompts
1286                .insert((task_id_clone.clone(), 1), initial_directive);
1287            // Link to the owner session (only Operator tokens match; Worker tokens have no session).
1288            if let Some(sess) = s.sessions.values_mut().find(|sess| sess.token_fp == fp) {
1289                sess.owned_task_ids.push(task_id_clone.clone());
1290            }
1291            s.push_event(Event::TaskCreated {
1292                task_id: task_id_clone.clone(),
1293            });
1294            Ok::<(), EngineError>(())
1295        })
1296        .await??;
1297        let _ = self.inner.event_tx.send(Event::TaskCreated {
1298            task_id: task_id.clone(),
1299        });
1300        Ok(task_id)
1301    }
1302
1303    /// Fetch a snapshot of `TaskState` for `task_id`, subject to the
1304    /// task-ownership gate (see `verify_token_for_task`).
1305    pub async fn read_task_state(
1306        &self,
1307        token: &CapToken,
1308        task_id: &StepId,
1309    ) -> Result<TaskState, EngineError> {
1310        self.verify_token_for_task(token, Verb::ReadTaskState, task_id)
1311            .await?;
1312        let task_id = task_id.clone();
1313        self.with_state("read_task_state", move |s| {
1314            s.tasks
1315                .get(&task_id)
1316                .cloned()
1317                .ok_or_else(|| EngineError::TaskNotFound(task_id.to_string()))
1318        })
1319        .await?
1320    }
1321
1322    /// Mark `task_id` as `Cancelled` and wake any caller blocked in
1323    /// `poll_task` for it.
1324    pub async fn cancel_task(&self, token: &CapToken, task_id: &StepId) -> Result<(), EngineError> {
1325        self.verify_token_for_task(token, Verb::CancelTask, task_id)
1326            .await?;
1327        let tid = task_id.clone();
1328        self.with_state("cancel_task", move |s| {
1329            let task = s
1330                .tasks
1331                .get_mut(&tid)
1332                .ok_or_else(|| EngineError::TaskNotFound(tid.to_string()))?;
1333            task.status = TaskStatus::Cancelled;
1334            task.updated_at = now_unix();
1335            s.push_event(Event::TaskCancelled {
1336                task_id: tid.clone(),
1337            });
1338            Ok::<(), EngineError>(())
1339        })
1340        .await??;
1341        self.wake_task(task_id).await?;
1342        Ok(())
1343    }
1344
1345    /// Dispatch a single attempt through the given `spawner`.
1346    ///
1347    /// The lock is only held for snapshot capture; the actual spawn and
1348    /// completion await happen outside the lock (R3 discipline).
1349    ///
1350    /// Sits on the Domain side of the Data / Domain split. The dispatch
1351    /// path itself does not touch big response bodies — those flow through
1352    /// the Data plane (`output_store` module + sink / input_inject
1353    /// `SpawnerLayer`s) around this method.
1354    ///
1355    /// The caller does the compile plus `service::linker::link` and
1356    /// carries the same stack through each dispatch. Because the spawner
1357    /// is passed per-request rather than looked up from engine-global
1358    /// state, parallel requests against a single `Engine` instance
1359    /// (different Blueprints, different spawners) do not race.
1360    ///
1361    /// `run_id`, when `Some` (issue #13 run_id propagation —
1362    /// `EngineDispatcher` threads it in from its `RunContext`), is
1363    /// inserted into `Ctx.meta.runtime["run_id"]` (a plain JSON string)
1364    /// alongside `worker_handle`, so `Operator::execute` implementations
1365    /// (e.g. `WSOperatorSession`) can read it back and surface it to the
1366    /// worker (Spawn directive / prompt). `None` (every pre-existing
1367    /// caller / test) omits the key entirely — unchanged behavior.
1368    pub async fn dispatch_attempt_with(
1369        &self,
1370        token: &CapToken,
1371        task_id: &StepId,
1372        spawner: &Arc<dyn SpawnerAdapter>,
1373        run_id: Option<&RunId>,
1374    ) -> Result<DispatchOutcome, EngineError> {
1375        self.verify_token(token, Verb::DispatchAttempt).await?;
1376        let task_id = task_id.clone();
1377
1378        // 1) Under the lock: increment the attempt number, mark Running, snapshot the
1379        //    prompt, and pull `operator_info` from the session so we can inject it into Ctx.
1380        let fp = token.fingerprint();
1381        let tid_for_prep = task_id.clone();
1382        let (attempt, agent, session_snapshot, step_ctx) = self
1383            .with_state("dispatch.prep", move |s| {
1384                let task = s
1385                    .tasks
1386                    .get_mut(&tid_for_prep)
1387                    .ok_or_else(|| EngineError::TaskNotFound(tid_for_prep.to_string()))?;
1388                task.attempt += 1;
1389                task.status = TaskStatus::Running;
1390                task.updated_at = now_unix();
1391                // The spawner pulls the prompt via engine.fetch_prompt. In prep,
1392                // if the prompts table has no entry for this attempt yet,
1393                // fall back and insert `initial_directive` so the subsequent
1394                // fetch_prompt succeeds.
1395                let attempt = task.attempt;
1396                let initial = task.spec.initial_directive.clone();
1397                s.prompts
1398                    .entry((tid_for_prep.clone(), attempt))
1399                    .or_insert(initial);
1400                let task = s
1401                    .tasks
1402                    .get(&tid_for_prep)
1403                    .ok_or_else(|| EngineError::TaskNotFound(tid_for_prep.to_string()))?;
1404                let agent = task.spec.agent.clone();
1405                // GH #21 Phase 2: re-read `TaskSpec.step_ctx` on EVERY
1406                // attempt (not cached once at start_task) so retries and
1407                // Run-rekicks all carry the Step tier through to Ctx —
1408                // see TaskSpec.step_ctx's doc.
1409                let step_ctx = task.spec.step_ctx.clone();
1410                // Session snapshot (looked up by token nonce). When no session
1411                // exists (worker token invoked directly / test injection), fall
1412                // back to None → default OperatorInfo.
1413                let sess_clone = s
1414                    .sessions
1415                    .values()
1416                    .find(|sess| sess.token_fp == fp)
1417                    .cloned();
1418                Ok::<_, EngineError>((attempt, agent, sess_clone, step_ctx))
1419            })
1420            .await??;
1421        // BridgeRegistry lookup + per-agent OperatorKind cascade.
1422        let operator_info = match session_snapshot {
1423            Some(sess) => self.resolve_operator_info(&sess, &agent).await,
1424            None => OperatorInfo::default(),
1425        };
1426
1427        // 2) Outside the lock: worker token mint + spawn.
1428        //
1429        // Session-style mint (max_uses=None). Within one attempt the worker is
1430        // expected to hit `verify_token + fetch_prompt + fetch_data + post_result`
1431        // multiple times in order, so `one_time` would exhaust the token on the
1432        // very first verb. Capability is guarded by (a) the role × verb gate and
1433        // (b) the short TTL (1800s).
1434        let worker_token = self.inner.signer.session(
1435            format!("worker-of-{task_id}"),
1436            Role::Worker,
1437            vec!["*".into()],
1438            Duration::from_secs(1800),
1439        );
1440        let worker_fp = worker_token.fingerprint();
1441        let task_id_for_worker = task_id.clone();
1442        let worker_token_for_store = worker_token.clone();
1443        self.with_state("dispatch.mint_worker", move |s| {
1444            s.tokens.insert(
1445                worker_fp,
1446                CapTokenRecord::from_worker_token(worker_token_for_store, task_id_for_worker),
1447            );
1448        })
1449        .await?;
1450
1451        // Mint a short handle (`wh-XXXXXXXX`) and register it in worker_handles.
1452        // Used by the simplified Bearer path for SubAgents (short-handle form
1453        // avoids base64 copy-paste incidents).
1454        let worker_handle = self.mint_worker_handle(worker_token.fingerprint()).await?;
1455
1456        let mut ctx = Ctx::new(task_id.clone(), attempt, agent.clone());
1457        ctx.operator = operator_info; // activates MainAIMiddleware / Senior bridge
1458        ctx.meta
1459            .runtime
1460            .insert("worker_handle".to_string(), Value::String(worker_handle));
1461        if let Some(rid) = run_id {
1462            ctx.meta
1463                .runtime
1464                .insert(RUN_ID_KEY.to_string(), Value::String(rid.to_string()));
1465        }
1466        // GH #21 Phase 2: the Step tier's resolved context bundle (from
1467        // `TaskSpec.step_ctx`, re-read every attempt above) — consumed by
1468        // `AgentContextMiddleware`, which unpacks its keys ahead of the
1469        // Agent / BP-global tiers.
1470        if let Some(step_ctx) = step_ctx {
1471            ctx.meta.runtime.insert(STEP_CTX_KEY.to_string(), step_ctx);
1472        }
1473
1474        let worker = spawner
1475            .spawn(self, &ctx, task_id.clone(), attempt, worker_token)
1476            .await
1477            .map_err(|e| EngineError::DispatchFailed(e.to_string()))?;
1478
1479        // 3) Outside the lock: await worker.join() (signal-only). WorkerError is
1480        //    stringified. The value is fetched via output_tail (sink path).
1481        let signal_result: Result<(), String> = worker.join().await.map_err(|e| e.to_string());
1482
1483        // Pull the last Final from output_tail and use it as the value. GH
1484        // #36 ST1 (named multi-part worker output): also fold every
1485        // `Artifact` the WORKER ITSELF staged on the same tail (via
1486        // `stage_worker_artifact_trusted` / `POST /v1/worker/artifact`)
1487        // into a `"parts"` object keyed by name — event order,
1488        // last-write-wins per name (a name staged twice overwrites,
1489        // mirroring `HashMap`/`Map` insert semantics, not an accumulating
1490        // list). `worker_artifact_names_for` is the allowlist that scopes
1491        // this to the worker's own opt-in parts — an `Artifact` some OTHER
1492        // producer appended to this same tail (e.g.
1493        // `AfterRunAuditMiddleware`'s `"audit:<step_ref>"` sidecar finding)
1494        // is left untouched (see `fold_final_and_parts`'s doc). When at
1495        // least one part was staged, the BP-chain value becomes `{"out":
1496        // <final value>, "parts": {...}}`; zero parts staged (the
1497        // pre-GH-#36 case, and every non-opt-in step) leaves the value
1498        // exactly the plain `Final` value, byte-identical to before this
1499        // change.
1500        let value_ok: Result<(Value, bool), String> = match signal_result {
1501            Ok(()) => {
1502                let tail = self.output_tail(&task_id, attempt).await;
1503                let staged_names = self.worker_artifact_names_for(&task_id, attempt).await;
1504                fold_final_and_parts(&tail, &staged_names)
1505                    .ok_or_else(|| "no Final in output_tail".to_string())
1506            }
1507            Err(msg) => Err(msg),
1508        };
1509
1510        // 4) Under the lock: apply (split the borrow scope so push_event and task mut can co-exist).
1511        let outcome = self
1512            .with_state("dispatch.apply", |s| {
1513                if !s.tasks.contains_key(&task_id) {
1514                    return Err(EngineError::TaskNotFound(task_id.to_string()));
1515                }
1516                match value_ok {
1517                    Ok((value, ok)) => {
1518                        let pass = ok;
1519                        {
1520                            let task = s.tasks.get_mut(&task_id).unwrap();
1521                            task.last_result = Some(value.clone());
1522                            task.updated_at = now_unix();
1523                            task.status = if pass {
1524                                TaskStatus::Pass
1525                            } else {
1526                                TaskStatus::Blocked
1527                            };
1528                        }
1529                        s.push_event(Event::TaskAttemptCompleted {
1530                            task_id: task_id.clone(),
1531                            attempt,
1532                            result: value.clone(),
1533                        });
1534                        if pass {
1535                            s.push_event(Event::TaskPass {
1536                                task_id: task_id.clone(),
1537                                result: value.clone(),
1538                            });
1539                            Ok::<_, EngineError>(DispatchOutcome::Pass(value))
1540                        } else {
1541                            s.push_event(Event::TaskBlocked {
1542                                task_id: task_id.clone(),
1543                                result: value.clone(),
1544                            });
1545                            Ok(DispatchOutcome::Blocked(value))
1546                        }
1547                    }
1548                    Err(msg) => {
1549                        let task = s.tasks.get_mut(&task_id).unwrap();
1550                        task.status = TaskStatus::Blocked;
1551                        task.updated_at = now_unix();
1552                        Err(EngineError::DispatchFailed(msg))
1553                    }
1554                }
1555            })
1556            .await??;
1557
1558        // event broadcast (outside the lock — push_event feeds the in-memory tail; broadcast is a separate path).
1559        let _ = self.inner.event_tx.send(Event::TaskAttemptCompleted {
1560            task_id: task_id.clone(),
1561            attempt,
1562            result: match &outcome {
1563                DispatchOutcome::Pass(v) | DispatchOutcome::Blocked(v) => v.clone(),
1564                _ => Value::Null,
1565            },
1566        });
1567
1568        // Wake any callers waiting in poll_task.
1569        self.wake_task(&task_id).await?;
1570
1571        Ok(outcome)
1572    }
1573
1574    // ═══════════════════════════════════════════════════════════════════════
1575    // Worker-side API (= prompt / data fetch + result post)
1576    // ═══════════════════════════════════════════════════════════════════════
1577
1578    /// Fetch the directive/prompt `Value` for `task_id`'s current attempt.
1579    /// Falls back to `initial_directive` when no prompt has been recorded
1580    /// yet for that attempt. Returns the `Value` end-to-end (issue #18);
1581    /// the render down to `String` happens only at the two consumer
1582    /// boundaries — the Worker HTTP path (`fetch_worker_payload*` →
1583    /// `WorkerPayload.prompt: String`) and the WS Spawn frame text
1584    /// render (`operator_ws::session`).
1585    pub async fn fetch_prompt(
1586        &self,
1587        token: &CapToken,
1588        task_id: &StepId,
1589    ) -> Result<Value, EngineError> {
1590        self.verify_token_for_task(token, Verb::FetchPrompt, task_id)
1591            .await?;
1592        let task_id = task_id.clone();
1593        self.with_state("fetch_prompt", move |s| {
1594            let task = s
1595                .tasks
1596                .get(&task_id)
1597                .ok_or_else(|| EngineError::TaskNotFound(task_id.to_string()))?;
1598            s.prompts
1599                .get(&(task_id.clone(), task.attempt.max(1)))
1600                .cloned()
1601                .ok_or_else(|| {
1602                    EngineError::ResourceNotFound(format!(
1603                        "prompt({}, attempt={})",
1604                        task_id, task.attempt
1605                    ))
1606                })
1607        })
1608        .await?
1609    }
1610
1611    /// Combined fetch for `HTTP /v1/worker/prompt`: returns `prompt` +
1612    /// (optional) `system` + `agent` + `attempt` in a single round trip.
1613    /// The verb gate reuses `FetchPrompt` — same semantics as "the worker
1614    /// pulls its task input".
1615    ///
1616    /// `system` is the value written by `OperatorSpawner::spawn` through
1617    /// `bake_worker_system_prompt` when it ran; otherwise `None` (no
1618    /// profile present, or the bake never happened).
1619    pub async fn fetch_worker_payload(
1620        &self,
1621        token: &CapToken,
1622        task_id: &StepId,
1623    ) -> Result<crate::types::WorkerPayload, EngineError> {
1624        self.verify_token_for_task(token, Verb::FetchPrompt, task_id)
1625            .await?;
1626        let task_id_clone = task_id.clone();
1627        let mut payload = self
1628            .with_state("fetch_worker_payload", move |s| {
1629                let task = s
1630                    .tasks
1631                    .get(&task_id_clone)
1632                    .ok_or_else(|| EngineError::TaskNotFound(task_id_clone.to_string()))?;
1633                let attempt = task.attempt.max(1);
1634                let prompt = s
1635                    .prompts
1636                    .get(&(task_id_clone.clone(), attempt))
1637                    .cloned()
1638                    .ok_or_else(|| {
1639                        EngineError::ResourceNotFound(format!(
1640                            "prompt({}, attempt={})",
1641                            task_id_clone, attempt
1642                        ))
1643                    })?;
1644                let system = s
1645                    .systems
1646                    .get(&(task_id_clone.clone(), attempt))
1647                    .cloned()
1648                    .unwrap_or(None);
1649                let agent = task.spec.agent.clone();
1650                let context = s
1651                    .agent_ctx
1652                    .get(&(task_id_clone.clone(), attempt))
1653                    .map(|e| e.view.clone());
1654                Ok::<_, EngineError>(crate::types::WorkerPayload {
1655                    task_id: task_id_clone.clone(),
1656                    attempt,
1657                    agent,
1658                    prompt: render_directive_to_string(&prompt),
1659                    system,
1660                    context,
1661                    system_ref: None,
1662                })
1663            })
1664            .await??;
1665        self.apply_system_ref_threshold(&mut payload).await?;
1666        Ok(payload)
1667    }
1668
1669    /// Fetch a worker payload via a short handle. Skips token verification
1670    /// and returns `prompt` + `system` + `agent` + `attempt` in a thin
1671    /// path. The caller is expected to have already resolved `task_id`
1672    /// via `task_id_from_handle` — the handle's presence in
1673    /// `worker_handles` means it was minted server-side and is therefore
1674    /// trusted.
1675    pub async fn fetch_worker_payload_trusted(
1676        &self,
1677        task_id: &StepId,
1678    ) -> Result<crate::types::WorkerPayload, EngineError> {
1679        let task_id_clone = task_id.clone();
1680        let mut payload = self
1681            .with_state("fetch_worker_payload_trusted", move |s| {
1682                let task = s
1683                    .tasks
1684                    .get(&task_id_clone)
1685                    .ok_or_else(|| EngineError::TaskNotFound(task_id_clone.to_string()))?;
1686                let attempt = task.attempt.max(1);
1687                let prompt = s
1688                    .prompts
1689                    .get(&(task_id_clone.clone(), attempt))
1690                    .cloned()
1691                    .ok_or_else(|| {
1692                        EngineError::ResourceNotFound(format!(
1693                            "prompt({}, attempt={})",
1694                            task_id_clone, attempt
1695                        ))
1696                    })?;
1697                let system = s
1698                    .systems
1699                    .get(&(task_id_clone.clone(), attempt))
1700                    .cloned()
1701                    .unwrap_or(None);
1702                let agent = task.spec.agent.clone();
1703                let context = s
1704                    .agent_ctx
1705                    .get(&(task_id_clone.clone(), attempt))
1706                    .map(|e| e.view.clone());
1707                Ok::<_, EngineError>(crate::types::WorkerPayload {
1708                    task_id: task_id_clone.clone(),
1709                    attempt,
1710                    agent,
1711                    prompt: render_directive_to_string(&prompt),
1712                    system,
1713                    context,
1714                    system_ref: None,
1715                })
1716            })
1717            .await??;
1718        self.apply_system_ref_threshold(&mut payload).await?;
1719        Ok(payload)
1720    }
1721
1722    /// GH #31: shared threshold-branch tail for
1723    /// [`Self::fetch_worker_payload`] / [`Self::fetch_worker_payload_trusted`].
1724    /// Both build a raw `WorkerPayload` inside `with_state` with `system`
1725    /// populated as before and `system_ref: None`; this runs *outside* any
1726    /// lock (R3 — `SystemRefMode::File`'s `tokio::fs` write is a genuine
1727    /// `.await`, which `with_state`'s sync-closure contract forbids inside
1728    /// the lock) and rewrites `payload.system` / `payload.system_ref` in
1729    /// place per `SystemRefConfig.threshold_bytes`: over-threshold clears
1730    /// `system` and populates `system_ref`; at-or-under-threshold leaves
1731    /// `system` as-is and `system_ref` stays `None`. A no-op when
1732    /// `payload.system` is already `None` (no `system_prompt` was baked).
1733    async fn apply_system_ref_threshold(
1734        &self,
1735        payload: &mut crate::types::WorkerPayload,
1736    ) -> Result<(), EngineError> {
1737        let Some(rendered) = payload.system.take() else {
1738            return Ok(());
1739        };
1740        let cfg = self.cfg().system_ref.clone();
1741        if rendered.len() <= cfg.threshold_bytes {
1742            payload.system = Some(rendered);
1743            return Ok(());
1744        }
1745        use sha2::Digest;
1746        let size_bytes = rendered.len() as u64;
1747        let sha256 = hex::encode(sha2::Sha256::digest(rendered.as_bytes()));
1748        let task_id = &payload.task_id;
1749        let attempt = payload.attempt;
1750        let system_ref = match cfg.mode {
1751            crate::types::SystemRefMode::Http => crate::types::SystemRef {
1752                // The engine has no knowledge of scheme/host here — see
1753                // `SystemRefMode::Http`'s doc for who fills that in.
1754                uri: format!("/v1/worker/prompt/system?task_id={task_id}&attempt={attempt}"),
1755                sha256,
1756                size_bytes,
1757                mode: crate::types::SystemRefMode::Http,
1758            },
1759            crate::types::SystemRefMode::File => {
1760                tokio::fs::create_dir_all(&cfg.store_dir).await?;
1761                let path = cfg.store_dir.join(format!("{task_id}-{attempt}.md"));
1762                tokio::fs::write(&path, rendered.as_bytes()).await?;
1763                crate::types::SystemRef {
1764                    uri: format!("file://{}", path.display()),
1765                    sha256,
1766                    size_bytes,
1767                    mode: crate::types::SystemRefMode::File,
1768                }
1769            }
1770        };
1771        payload.system = None;
1772        payload.system_ref = Some(system_ref);
1773        Ok(())
1774    }
1775
1776    /// Returns the effective [`mlua_swarm_schema::ContextPolicy`]
1777    /// `AgentContextMiddleware` resolved and snapshotted for `(task_id,
1778    /// attempt)` at spawn time (the same policy already applied to that
1779    /// key's `EngineState.agent_ctx` entry's `.view`, GH #23 fold).
1780    /// Pass-all (`ContextPolicy::default()`) when no entry exists — either
1781    /// a pre-ST5 spawn, or a spawner stack that never layered
1782    /// `AgentContextMiddleware` (fail-open, mirroring [`Self::output_tail`]'s
1783    /// "no entry = empty default" convention).
1784    ///
1785    /// `crates/mlua-swarm-server/src/worker.rs`'s `GET /v1/worker/prompt`
1786    /// handler reads this back to filter `WorkerPayload.context.steps` via
1787    /// `ContextPolicy::allows_step`, without re-deriving the policy from
1788    /// the Blueprint at fetch time (`projection-adapter` ST5).
1789    pub async fn context_policy_for(
1790        &self,
1791        task_id: &StepId,
1792        attempt: u32,
1793    ) -> mlua_swarm_schema::ContextPolicy {
1794        let key = (task_id.clone(), attempt);
1795        self.with_state("context_policy_for", move |s| {
1796            s.agent_ctx
1797                .get(&key)
1798                .map(|e| e.policy.clone())
1799                .unwrap_or_default()
1800        })
1801        .await
1802        .unwrap_or_default()
1803    }
1804
1805    /// GH #23: returns the Blueprint-wide
1806    /// [`crate::core::step_naming::StepNaming`] table snapshotted for
1807    /// `task_id` (the same `Arc` `crate::blueprint::EngineDispatcher::dispatch`
1808    /// stashed into `EngineState.step_namings` at dispatch time —
1809    /// `Self::start_task`'s `StepId`, not the `TaskId` work item). `None`
1810    /// when no entry exists — either the dispatcher was never given a
1811    /// `StepNaming` (`EngineDispatcher::with_step_naming` not called) or
1812    /// the lock could not be acquired; callers are expected to fall back
1813    /// to the pre-GH-#23 runtime union rule in that case (subtask-2/3
1814    /// consumers).
1815    pub async fn step_naming_for(
1816        &self,
1817        task_id: &StepId,
1818    ) -> Option<Arc<crate::core::step_naming::StepNaming>> {
1819        let key = task_id.clone();
1820        self.with_state("step_naming_for", move |s| {
1821            s.step_namings.get(&key).cloned()
1822        })
1823        .await
1824        .ok()
1825        .flatten()
1826    }
1827
1828    /// GH #27 (follow-up to #23): returns the Blueprint-wide
1829    /// [`crate::core::projection_placement::ProjectionPlacement`] resolver
1830    /// snapshotted for `task_id` (the same `Arc`
1831    /// `crate::blueprint::EngineDispatcher::dispatch` stashed into
1832    /// `EngineState.projection_placements` at dispatch time — mirroring
1833    /// [`Self::step_naming_for`]'s contract exactly). `None` when no entry
1834    /// exists — either the dispatcher was never given a
1835    /// `ProjectionPlacement` (`EngineDispatcher::with_projection_placement`
1836    /// not called) or the lock could not be acquired; callers are expected
1837    /// to fall back to `ProjectionPlacement::default()` (byte-compat with
1838    /// the pre-#27 hardcoded layout) in that case.
1839    pub async fn projection_placement_for(
1840        &self,
1841        task_id: &StepId,
1842    ) -> Option<Arc<crate::core::projection_placement::ProjectionPlacement>> {
1843        let key = task_id.clone();
1844        self.with_state("projection_placement_for", move |s| {
1845            s.projection_placements.get(&key).cloned()
1846        })
1847        .await
1848        .ok()
1849        .flatten()
1850    }
1851
1852    /// Returns the [`crate::core::agent_context::AgentContextView`]
1853    /// snapshotted for `(task_id, attempt)`, if `AgentContextMiddleware`
1854    /// stashed one — the same lookup [`Self::fetch_worker_payload`] /
1855    /// [`Self::fetch_worker_payload_trusted`] perform inline, exposed
1856    /// standalone for callers that only need the view (not a full
1857    /// `WorkerPayload`) — e.g. the HTTP debug-plane `GET
1858    /// /v1/tasks/:id/runs/:run/steps*` handlers resolving a
1859    /// materialized-file root for a step *other than* the one currently
1860    /// fetching its own prompt (`projection-adapter` ST5).
1861    pub async fn agent_context_for(
1862        &self,
1863        task_id: &StepId,
1864        attempt: u32,
1865    ) -> Option<crate::core::agent_context::AgentContextView> {
1866        let key = (task_id.clone(), attempt);
1867        self.with_state("agent_context_for", move |s| {
1868            s.agent_ctx.get(&key).map(|e| e.view.clone())
1869        })
1870        .await
1871        .ok()
1872        .flatten()
1873    }
1874
1875    /// Read the current attempt number for a task (server-side lookup, no
1876    /// token verification). Used on `HTTP /v1/worker/result` when the
1877    /// worker omits `attempt` and the server has to fill it in.
1878    pub async fn task_attempt(&self, task_id: &StepId) -> Result<u32, EngineError> {
1879        let task_id = task_id.clone();
1880        self.with_state("task_attempt", move |s| {
1881            s.tasks
1882                .get(&task_id)
1883                .map(|t| t.attempt)
1884                .ok_or_else(|| EngineError::TaskNotFound(task_id.to_string()))
1885        })
1886        .await?
1887    }
1888
1889    /// Server-side admin API that lets `OperatorSpawner::spawn` bake the
1890    /// rendered `system_prompt` into engine state. There is no verb gate
1891    /// — the only expected caller is inside the spawner. SubAgents fetch
1892    /// this alongside the prompt on the `/v1/worker/prompt` path.
1893    pub async fn bake_worker_system_prompt(
1894        &self,
1895        task_id: &StepId,
1896        attempt: u32,
1897        system: Option<String>,
1898    ) -> Result<(), EngineError> {
1899        let task_id = task_id.clone();
1900        self.with_state("bake_worker_system_prompt", move |s| {
1901            // GH #31: record this agent's most-recently-baked render size
1902            // before `system` is moved into `s.systems.insert` below. Same
1903            // `s.tasks.get(&task_id)` → `.spec.agent` lookup pattern
1904            // `fetch_worker_payload` uses (see its doc for why this keying
1905            // is load-bearing for a later `bp_doctor` route).
1906            if let Some(rendered) = system.as_ref() {
1907                if let Some(agent) = s.tasks.get(&task_id).map(|t| t.spec.agent.clone()) {
1908                    s.agent_render_sizes.insert(agent, rendered.len());
1909                }
1910            }
1911            s.systems.insert((task_id, attempt), system);
1912        })
1913        .await?;
1914        Ok(())
1915    }
1916
1917    /// GH #31: the most-recently-baked `system_prompt` render size (in
1918    /// bytes) observed for `agent_name`, if `bake_worker_system_prompt` has
1919    /// ever recorded one — last-write-wins across every `(task_id,
1920    /// attempt)` dispatch of that agent. `None` when no `system_prompt`
1921    /// has ever been baked for this agent name. Read by the `bp_doctor`
1922    /// route this subtask's follow-up adds.
1923    pub async fn agent_last_rendered_size(&self, agent_name: &str) -> Option<usize> {
1924        let agent_name = agent_name.to_string();
1925        self.with_state("agent_last_rendered_size", move |s| {
1926            s.agent_render_sizes.get(&agent_name).copied()
1927        })
1928        .await
1929        .ok()
1930        .flatten()
1931    }
1932
1933    /// GH #31: plain read-through of the baked `system` string for
1934    /// `(task_id, attempt)` from `EngineState.systems`, with no threshold
1935    /// branching. Backs `GET /v1/worker/prompt/system` (the `Http`-mode
1936    /// fetch target `system_ref.uri` points at) — that route needs the
1937    /// exact raw bytes to serve as the response body for the client's
1938    /// sha256 verification, not a `WorkerPayload`-wrapped value.
1939    ///
1940    /// Distinct from `apply_system_ref_threshold` (private, mutates an
1941    /// already-built `WorkerPayload` in place after full construction):
1942    /// this accessor has no threshold logic and is `pub` so
1943    /// `mlua-swarm-server`'s `worker` module can call it directly.
1944    ///
1945    /// Returns `Ok(None)` if no baked system exists for that `(task_id,
1946    /// attempt)` (either the task/attempt has no entry in `s.systems`, or
1947    /// the entry is present but stores `None`) — the caller maps this to
1948    /// a 404.
1949    pub async fn raw_system_prompt(
1950        &self,
1951        task_id: &StepId,
1952        attempt: u32,
1953    ) -> Result<Option<String>, EngineError> {
1954        let task_id = task_id.clone();
1955        self.with_state("raw_system_prompt", move |s| {
1956            s.systems.get(&(task_id, attempt)).cloned().unwrap_or(None)
1957        })
1958        .await
1959    }
1960
1961    /// Fetch an arbitrary named resource previously stored via
1962    /// `set_resource`. Not task-scoped — any valid token with the
1963    /// `FetchData` verb may read any key.
1964    pub async fn fetch_data(&self, token: &CapToken, key: &str) -> Result<Value, EngineError> {
1965        self.verify_token(token, Verb::FetchData).await?;
1966        let key = key.to_string();
1967        self.with_state("fetch_data", move |s| {
1968            s.resources
1969                .get(&key)
1970                .cloned()
1971                .ok_or(EngineError::ResourceNotFound(key))
1972        })
1973        .await?
1974    }
1975
1976    // ───────────────────────────────────────────────────────────────────────
1977    // Output path.
1978    // ───────────────────────────────────────────────────────────────────────
1979
1980    /// Send one output event from inside a `SpawnerAdapter` or worker.
1981    /// Structuring is assumed to be complete by the time we cross the
1982    /// `SpawnerAdapter` boundary; this API just appends to the
1983    /// `OutputStore`, pushes to the `EventLog`, and (for `Final`) emits
1984    /// the `TaskAttemptCompleted` event.
1985    ///
1986    /// This is Domain-side plumbing: it feeds the engine's verdict flow,
1987    /// not the Data-plane store in the `output_store` module. It also
1988    /// does not wake the dispatch path — that is done through the
1989    /// spawner's completion oneshot when the worker terminates.
1990    ///
1991    /// # Submit-time projection sink (subtask-4 / ST2 rework)
1992    ///
1993    /// A `Final` event additionally fans out to the submit-time projection
1994    /// sink ([`Self::materialize_final_submission`]): (a) when
1995    /// [`Self::set_output_store`] has wired a Data-plane
1996    /// [`crate::store::output::OutputStore`], the event is dual-written
1997    /// there (`producer_agent` = `TaskState.spec.agent`, resolved to its
1998    /// GH #23 canonical projection name — see below), and (b) when this
1999    /// task's spawn ran through `AgentContextMiddleware` (so
2000    /// `EngineState.agent_ctx` has a `.view.work_dir` / `.view.project_root`
2001    /// for it), the value is additionally materialized to the
2002    /// [`crate::core::projection_placement::ProjectionPlacement`]
2003    /// resolver's target (byte-compat default layout
2004    /// `<root>/workspace/tasks/<task_id>/ctx/<canonical_agent>.md`) — see
2005    /// `crate::core::projection`'s module doc.
2006    ///
2007    /// **GH #23 subtask-2 (canonical sink):** both writes above key off the
2008    /// canonical name — `Engine::step_naming_for(task_id)`'s
2009    /// `StepNaming::canonical_of_producer(producer_agent)` when a table was
2010    /// snapshotted for this task (`EngineDispatcher::with_step_naming`),
2011    /// else `producer_agent` unchanged (fail-open, byte-identical to
2012    /// pre-GH-#23 behavior — see [`crate::core::step_naming`]'s module
2013    /// doc).
2014    ///
2015    /// **Invariants** (Subtask 4): (1) this sink is fail-open — an
2016    /// unresolved root, an unconfigured `OutputStore`, or either one
2017    /// erroring, only logs a `tracing::warn!` and never turns this
2018    /// `Ok(())` into an `Err`; (2) the wired `OutputStore` stays the single
2019    /// source of truth for cross-step queries — the materialized file is a
2020    /// projection of it, not a second store; (3) core does not depend on
2021    /// `mlua-swarm-server` — everything this sink touches
2022    /// (`crate::store::output` / `crate::core::projection`) already lives
2023    /// in this crate.
2024    ///
2025    /// # `Artifact` dual-write (GH #34 subtask-3 gap fix)
2026    ///
2027    /// An `Artifact` event ALSO fans out to the Data-plane, via
2028    /// [`Self::materialize_artifact_submission`] — general-form: every
2029    /// `Artifact` submitted through this API dual-writes, no name-prefix
2030    /// gate. Unlike `Final`, the dual-write key is the artifact's own
2031    /// `name` field, verbatim — NOT resolved through the GH #23 canonical
2032    /// `StepNaming` table. An artifact's `name` IS its identity (mirrors
2033    /// [`crate::store::output::OutputStore::get_latest_by_name`]'s doc),
2034    /// so no canonicalization applies. Same fail-open discipline as
2035    /// `Final` (Invariant 1 above), but `Artifact` does NOT drive the
2036    /// file-materialize half (b) — artifact findings (e.g.
2037    /// `AfterRunAuditMiddleware`'s `"audit:<step_ref>"`) are observational
2038    /// sidecar data, not a step's own submission a work_dir/project_root
2039    /// projection needs to track. `Progress` / `Partial` events are
2040    /// unaffected — no behavior change.
2041    pub async fn submit_output(
2042        &self,
2043        token: &crate::types::CapToken,
2044        task_id: &StepId,
2045        attempt: u32,
2046        event: crate::worker::output::OutputEvent,
2047    ) -> Result<(), EngineError> {
2048        self.verify_token_for_task(token, crate::types::Verb::EmitOutput, task_id)
2049            .await?;
2050        // GH #51 — completion-time verdict-contract enforcement, embedded
2051        // choke point 2 of 2 (see `Self::verdict_contract_completion_check`'s
2052        // doc). Guarded to `Final` only — the ONLY `OutputEvent` variant a
2053        // verdict contract's completion can meaningfully address; this
2054        // guard is defensive (this function is empirically called with
2055        // `Final` only today, both from `worker.rs`'s `worker_result` and
2056        // from `operator.rs`'s WS fallback) but costs nothing and protects
2057        // against a future non-`Final` caller. Runs BEFORE the
2058        // `output_tail` write immediately below: on `Err`, this returns
2059        // immediately and the write never happens — a rejected value
2060        // never reaches `output_tail` / the flow ctx.
2061        if let crate::worker::output::OutputEvent::Final { content, ok } = &event {
2062            let comparable_value = content_ref_to_comparable_string(content.clone());
2063            self.verdict_contract_completion_check(task_id, attempt, *ok, &comparable_value)
2064                .await?;
2065        }
2066        let task_id_for_apply = task_id.clone();
2067        let event_clone = event.clone();
2068        self.with_state("submit_output", move |s| {
2069            s.output_store
2070                .entry((task_id_for_apply.clone(), attempt))
2071                .or_default()
2072                .push(event_clone.clone());
2073            s.push_event(crate::core::state::Event::WorkerOutput {
2074                task_id: task_id_for_apply,
2075                attempt,
2076                event: event_clone,
2077            });
2078        })
2079        .await?;
2080        match &event {
2081            crate::worker::output::OutputEvent::Final { content, ok } => {
2082                self.materialize_final_submission(task_id, attempt, content, *ok)
2083                    .await?;
2084            }
2085            crate::worker::output::OutputEvent::Artifact { name, content } => {
2086                self.materialize_artifact_submission(task_id, attempt, name, content)
2087                    .await?;
2088            }
2089            _ => {}
2090        }
2091        Ok(())
2092    }
2093
2094    /// Submit-time projection sink (subtask-4 / ST2 rework) shared by
2095    /// [`Self::submit_output`] and [`Self::submit_worker_result_trusted`].
2096    /// Best-effort / fail-open throughout (see `submit_output`'s doc
2097    /// Invariants): every failure path only `tracing::warn!`s and returns.
2098    ///
2099    /// Reads `(producer_agent, view)` via one read-only [`Self::with_state`]
2100    /// call — `producer_agent` off `TaskState.spec.agent`, `view` (the
2101    /// full [`crate::core::agent_context::AgentContextView`]) off
2102    /// `EngineState.agent_ctx[(task_id, attempt)]`, the same snapshot
2103    /// `crate::middleware::agent_context::AgentContextMiddleware` writes at
2104    /// spawn time — then does its actual (dual-write / file-write) work
2105    /// *outside* that lock, so a slow disk write or Data-plane store call
2106    /// never holds up unrelated `Engine::with_state` callers. `root` itself
2107    /// is resolved from `view` AFTER the lock via
2108    /// [`crate::core::projection_placement::ProjectionPlacement::resolve_root`]
2109    /// (GH #27, follow-up to #23) — the SAME resolver
2110    /// [`Self::step_naming_for`]'s sibling accessor
2111    /// [`Self::projection_placement_for`] snapshotted at dispatch time, so
2112    /// this sink's root-preference / fallback order is identical to the
2113    /// server read-back and the spawn-time pointer.
2114    async fn materialize_final_submission(
2115        &self,
2116        task_id: &StepId,
2117        attempt: u32,
2118        content: &crate::worker::output::ContentRef,
2119        ok: bool,
2120    ) -> Result<(), EngineError> {
2121        let server_policy = self.cfg().check_policy;
2122        let task_id_for_lookup = task_id.clone();
2123        let lookup = self
2124            .with_state("materialize_final_submission.lookup", move |s| {
2125                let entry = s.tasks.get(&task_id_for_lookup);
2126                let producer_agent = entry.map(|t| t.spec.agent.clone());
2127                let task_policy = entry.and_then(|t| t.spec.check_policy);
2128                let view = s
2129                    .agent_ctx
2130                    .get(&(task_id_for_lookup.clone(), attempt))
2131                    .map(|e| e.view.clone());
2132                (producer_agent, task_policy, view)
2133            })
2134            .await;
2135        // Per-task `TaskSpec.check_policy` (ST1c) wins
2136        // over the server-wide `EngineCfg.check_policy` when set — a
2137        // per-run override forwarded from the launch entry point (see
2138        // `TaskLaunchRequest.check_policy` /
2139        // `TaskLaunchInput.check_policy`). `None` leaves the server
2140        // default in effect (backward compat).
2141        let policy = lookup
2142            .as_ref()
2143            .ok()
2144            .and_then(|(_, tp, _)| *tp)
2145            .unwrap_or(server_policy);
2146        let (producer_agent, view) = match lookup.map(|(pa, _, view)| (pa, view)) {
2147            Ok(pair) => pair,
2148            Err(err) => {
2149                if !matches!(policy, crate::core::config::CheckPolicy::Silent) {
2150                    tracing::warn!(
2151                        %task_id,
2152                        error = %err,
2153                        "submit-time projection sink: state lookup failed; skipping (fail-open)"
2154                    );
2155                }
2156                apply_check_policy(
2157                    policy,
2158                    "submit-time projection sink: state lookup",
2159                    "state lookup failed; skipping (fail-open)",
2160                )?;
2161                return Ok(());
2162            }
2163        };
2164        let Some(producer_agent) = producer_agent else {
2165            // Defensive only: `task_id` is always a just-looked-up task at
2166            // every real call site. No task, no addressable producer name
2167            // — nothing to project. Not gated by `CheckPolicy` — a missing
2168            // task is an intentional early-exit path, not a fail-open
2169            // condition to surface.
2170            return Ok(());
2171        };
2172        let placement = self
2173            .projection_placement_for(task_id)
2174            .await
2175            .unwrap_or_default();
2176        let root = view.and_then(|v| placement.resolve_root(&v));
2177
2178        // GH #23 subtask-2: resolve `producer_agent` to its canonical
2179        // projection name via the Blueprint-wide `StepNaming` table
2180        // snapshotted at dispatch time (`Engine::step_naming_for`). Both
2181        // write paths below ((a) data-plane, (b) file stem) use the
2182        // *canonical* name — `StepNaming::canonical_of_producer` returns
2183        // `producer_agent` unchanged for undeclared steps (byte-identical
2184        // to pre-GH-#23 behavior), and `None` (no table for this
2185        // `task_id`, e.g. a spawn that never went through
2186        // `EngineDispatcher::with_step_naming`) is a defensive fail-open
2187        // to the raw `producer_agent`, same discipline as the rest of this
2188        // sink.
2189        let canonical_agent = self
2190            .step_naming_for(task_id)
2191            .await
2192            .and_then(|naming| {
2193                naming
2194                    .canonical_of_producer(&producer_agent)
2195                    .map(str::to_string)
2196            })
2197            .unwrap_or_else(|| producer_agent.clone());
2198
2199        // (a) Data-plane dual-write, when an OutputStore backend is wired.
2200        if let Some(store) = self.output_store_backend() {
2201            if let Err(err) = store
2202                .append(
2203                    task_id.as_str(),
2204                    attempt,
2205                    &canonical_agent,
2206                    crate::worker::output::OutputEvent::Final {
2207                        content: content.clone(),
2208                        ok,
2209                    },
2210                    Vec::new(),
2211                )
2212                .await
2213            {
2214                if !matches!(policy, crate::core::config::CheckPolicy::Silent) {
2215                    tracing::warn!(
2216                        %task_id,
2217                        agent = %producer_agent,
2218                        canonical = %canonical_agent,
2219                        error = %err,
2220                        "submit-time projection sink: OutputStore dual-write failed (fail-open)"
2221                    );
2222                }
2223                apply_check_policy(
2224                    policy,
2225                    "submit-time projection sink: OutputStore dual-write",
2226                    "OutputStore dual-write failed (fail-open)",
2227                )?;
2228            }
2229        }
2230
2231        // (b) File materialize, when a root resolved.
2232        let Some(root) = root else {
2233            if !matches!(policy, crate::core::config::CheckPolicy::Silent) {
2234                tracing::warn!(
2235                    %task_id,
2236                    agent = %producer_agent,
2237                    canonical = %canonical_agent,
2238                    "submit-time projection sink: no work_dir/project_root resolved; skipping file materialize (fail-open)"
2239                );
2240            }
2241            apply_check_policy(
2242                policy,
2243                "submit-time projection sink: file materialize",
2244                "no work_dir/project_root resolved; skipping file materialize (fail-open)",
2245            )?;
2246            return Ok(());
2247        };
2248        let value = match content {
2249            crate::worker::output::ContentRef::Inline { value } => value.clone(),
2250            crate::worker::output::ContentRef::FileRef {
2251                path,
2252                mime,
2253                size_hint,
2254            } => serde_json::json!({
2255                "file_ref": path.to_string_lossy(),
2256                "mime": mime,
2257                "size_hint": size_hint,
2258            }),
2259        };
2260        let key = crate::core::projection::ProjectionKey {
2261            task_id: task_id.to_string(),
2262            run_id: None,
2263            step: Some(canonical_agent.clone()),
2264            path: None,
2265        };
2266        let adapter = crate::core::projection::FileProjectionAdapter::with_placement(
2267            root,
2268            (*placement).clone(),
2269        );
2270        if let Err(err) = adapter.materialize_submission(&key, &value, attempt, ok) {
2271            if !matches!(policy, crate::core::config::CheckPolicy::Silent) {
2272                tracing::warn!(
2273                    %task_id,
2274                    agent = %producer_agent,
2275                    canonical = %canonical_agent,
2276                    error = %err,
2277                    "submit-time projection sink: file materialize failed (fail-open)"
2278                );
2279            }
2280            apply_check_policy(
2281                policy,
2282                "submit-time projection sink: file materialize",
2283                "file materialize failed (fail-open)",
2284            )?;
2285        }
2286        Ok(())
2287    }
2288
2289    /// Submit-time projection sink for `OutputEvent::Artifact` (GH #34
2290    /// subtask-3, later extended to drive the file half too). Two halves, the
2291    /// [`Self::materialize_final_submission`] mirror for staged named parts:
2292    ///
2293    /// - **Data-plane dual-write** — when [`Self::set_output_store`] has
2294    ///   wired a [`crate::store::output::OutputStore`], the artifact
2295    ///   dual-writes there under its own `name`, verbatim (general form:
2296    ///   every `Artifact` staged via [`Self::submit_output`] /
2297    ///   [`Self::stage_worker_artifact_trusted`] materializes this way, no
2298    ///   name-prefix gate).
2299    /// - **File materialize** — when a `root` resolves off the spawn-time
2300    ///   [`crate::core::agent_context::AgentContextView`], the part's
2301    ///   content is written raw to `<ctx-dir>/<name>` via
2302    ///   [`crate::core::projection::FileProjectionAdapter::materialize_part`].
2303    ///   That file is the IN file the *next* Agent step reads: materializing
2304    ///   a Step's OUTPUT to disk is the
2305    ///   [`crate::core::projection::FileProjectionAdapter`]'s
2306    ///   responsibility, and a staged named part is as much an OUTPUT the
2307    ///   next step consumes as a `Final` is — so the sink materializes it
2308    ///   too, rather than leaving parts Data-plane-only.
2309    ///
2310    /// Unlike the Final sink, no `StepNaming` canonicalization is applied:
2311    /// an artifact's `name` already IS the key both halves address (it
2312    /// names the file directly, extension included — `plan.md` — so
2313    /// `materialize_part` writes it verbatim, not through the `<stem>.md`
2314    /// synthesis the Final sink's canonical-agent path uses).
2315    ///
2316    /// Fail-open throughout, the same `check_policy` cascade as
2317    /// [`Self::materialize_final_submission`]: a per-task lookup error falls
2318    /// back to the server default (and a `None` view ⇒ the file half's
2319    /// unresolved-root path), an unconfigured `OutputStore` skips the
2320    /// dual-write, an unresolved root skips the file half, and a
2321    /// dual-write / file-write / name-guard error only `tracing::warn!`s
2322    /// (`Silent` suppresses even that) before applying [`apply_check_policy`]
2323    /// (`Strict` surfaces an [`EngineError`], `Warn` / `Silent` return
2324    /// `Ok(())`) — a staged part never turns a would-have-succeeded submit
2325    /// into a failure under the default policy.
2326    async fn materialize_artifact_submission(
2327        &self,
2328        task_id: &StepId,
2329        attempt: u32,
2330        name: &str,
2331        content: &crate::worker::output::ContentRef,
2332    ) -> Result<(), EngineError> {
2333        // Per-task `TaskSpec.check_policy` override + the `AgentContextView`
2334        // snapshot, resolved in ONE read-only `with_state` (the same lock
2335        // the policy lookup already needed — no extra `with_state` for the
2336        // view). Silent per-task lookup failure (`with_state` error) falls
2337        // back to the server-wide default and a `None` view (⇒ the file
2338        // half's own unresolved-root fail-open path); this sink never
2339        // surfaces the lookup error itself as a step failure.
2340        let server_policy = self.cfg().check_policy;
2341        let task_id_for_lookup = task_id.clone();
2342        let lookup = self
2343            .with_state("materialize_artifact_submission.lookup", move |s| {
2344                let task_policy = s
2345                    .tasks
2346                    .get(&task_id_for_lookup)
2347                    .and_then(|t| t.spec.check_policy);
2348                let view = s
2349                    .agent_ctx
2350                    .get(&(task_id_for_lookup.clone(), attempt))
2351                    .map(|e| e.view.clone());
2352                (task_policy, view)
2353            })
2354            .await
2355            .ok();
2356        let policy = lookup
2357            .as_ref()
2358            .and_then(|(tp, _)| *tp)
2359            .unwrap_or(server_policy);
2360        let view = lookup.and_then(|(_, view)| view);
2361
2362        // (a) Data-plane dual-write, when an OutputStore backend is wired —
2363        // the artifact's own `name` is its Data-plane key (no
2364        // canonicalization, unlike the Final sink's `StepNaming`
2365        // resolution).
2366        if let Some(store) = self.output_store_backend() {
2367            if let Err(err) = store
2368                .append(
2369                    task_id.as_str(),
2370                    attempt,
2371                    name,
2372                    crate::worker::output::OutputEvent::Artifact {
2373                        name: name.to_string(),
2374                        content: content.clone(),
2375                    },
2376                    Vec::new(),
2377                )
2378                .await
2379            {
2380                if !matches!(policy, crate::core::config::CheckPolicy::Silent) {
2381                    tracing::warn!(
2382                        %task_id,
2383                        artifact = %name,
2384                        error = %err,
2385                        "submit-time projection sink: OutputStore dual-write failed for Artifact (fail-open)"
2386                    );
2387                }
2388                apply_check_policy(
2389                    policy,
2390                    "submit-time projection sink: Artifact OutputStore dual-write",
2391                    "OutputStore dual-write failed for Artifact (fail-open)",
2392                )?;
2393            }
2394        }
2395
2396        // (b) File materialize, when a root resolved — writes the staged
2397        // part raw to `<ctx-dir>/<name>`, the IN file the next Agent step
2398        // reads (see `FileProjectionAdapter::materialize_part`'s doc for
2399        // why raw / why the name is verbatim). A name-guard violation lands
2400        // on the same fail-open path as any other write error below.
2401        let placement = self
2402            .projection_placement_for(task_id)
2403            .await
2404            .unwrap_or_default();
2405        let Some(root) = view.and_then(|v| placement.resolve_root(&v)) else {
2406            if !matches!(policy, crate::core::config::CheckPolicy::Silent) {
2407                tracing::warn!(
2408                    %task_id,
2409                    artifact = %name,
2410                    "submit-time projection sink: no work_dir/project_root resolved; skipping part file materialize (fail-open)"
2411                );
2412            }
2413            apply_check_policy(
2414                policy,
2415                "submit-time projection sink: part file materialize",
2416                "no work_dir/project_root resolved; skipping part file materialize (fail-open)",
2417            )?;
2418            return Ok(());
2419        };
2420        let value = match content {
2421            crate::worker::output::ContentRef::Inline { value } => value.clone(),
2422            crate::worker::output::ContentRef::FileRef {
2423                path,
2424                mime,
2425                size_hint,
2426            } => serde_json::json!({
2427                "file_ref": path.to_string_lossy(),
2428                "mime": mime,
2429                "size_hint": size_hint,
2430            }),
2431        };
2432        let adapter = crate::core::projection::FileProjectionAdapter::with_placement(
2433            root,
2434            (*placement).clone(),
2435        );
2436        if let Err(err) = adapter.materialize_part(task_id.as_str(), name, &value) {
2437            if !matches!(policy, crate::core::config::CheckPolicy::Silent) {
2438                tracing::warn!(
2439                    %task_id,
2440                    artifact = %name,
2441                    error = %err,
2442                    "submit-time projection sink: part file materialize failed (fail-open)"
2443                );
2444            }
2445            apply_check_policy(
2446                policy,
2447                "submit-time projection sink: part file materialize",
2448                "part file materialize failed (fail-open)",
2449            )?;
2450        }
2451        Ok(())
2452    }
2453
2454    /// Snapshot the entire output tail for a given `(task_id, attempt)`.
2455    /// Used by the dispatch path when pulling `Final`, and by observers
2456    /// reading the trace.
2457    pub async fn output_tail(
2458        &self,
2459        task_id: &StepId,
2460        attempt: u32,
2461    ) -> Vec<crate::worker::output::OutputEvent> {
2462        let key = (task_id.clone(), attempt);
2463        self.with_state("output_tail", move |s| {
2464            s.output_store.get(&key).cloned().unwrap_or_default()
2465        })
2466        .await
2467        .unwrap_or_default()
2468    }
2469
2470    /// Record an interim `last_result` for `task_id` without changing its
2471    /// `status`. Distinct from the terminal `Final` output event handled
2472    /// through `submit_output` / `dispatch_attempt_with`.
2473    pub async fn post_result(
2474        &self,
2475        token: &CapToken,
2476        task_id: &StepId,
2477        result: Value,
2478    ) -> Result<(), EngineError> {
2479        self.verify_token_for_task(token, Verb::PostResult, task_id)
2480            .await?;
2481        let task_id = task_id.clone();
2482        let result_clone = result.clone();
2483        self.with_state("post_result", move |s| {
2484            let task = s
2485                .tasks
2486                .get_mut(&task_id)
2487                .ok_or_else(|| EngineError::TaskNotFound(task_id.to_string()))?;
2488            task.last_result = Some(result_clone);
2489            task.updated_at = now_unix();
2490            Ok::<(), EngineError>(())
2491        })
2492        .await??;
2493        Ok(())
2494    }
2495
2496    /// Store a named resource value, retrievable later via `fetch_data`.
2497    /// No token is required — this is a server-side/admin-style setter
2498    /// (mirrors `bake_worker_system_prompt`).
2499    pub async fn set_resource(
2500        &self,
2501        key: impl Into<String>,
2502        value: Value,
2503    ) -> Result<(), EngineError> {
2504        let key = key.into();
2505        self.with_state("set_resource", move |s| {
2506            s.resources.insert(key, value);
2507        })
2508        .await?;
2509        Ok(())
2510    }
2511
2512    // ═══════════════════════════════════════════════════════════════════════
2513    // Senior suspend / resume
2514    // ═══════════════════════════════════════════════════════════════════════
2515
2516    /// Ask a question of the Senior, mark the task `Suspended`, and
2517    /// return a `ResumeKey`. The suspended state persists until another
2518    /// task calls `resume(key, answer)`.
2519    ///
2520    /// Resume-side waiting is `Notify`-based, so a caller (typically
2521    /// MainAI) can detach, reattach from a different process, and still
2522    /// pull the answer out via `await_resume(key, timeout)` — the answer
2523    /// is stored inside `EngineState`.
2524    pub async fn query_senior(
2525        &self,
2526        token: &CapToken,
2527        task_id: &StepId,
2528        question: Value,
2529    ) -> Result<ResumeKey, EngineError> {
2530        self.verify_token(token, Verb::QuerySenior).await?;
2531        let task_id = task_id.clone();
2532        let key = ResumeKey::for_senior(&task_id);
2533        let task_notify = self
2534            .with_state("query_senior.notify_ensure", |s| {
2535                s.ensure_task_notify(&task_id)
2536            })
2537            .await?;
2538
2539        let key_clone = key.clone();
2540        let task_id_inner = task_id.clone();
2541        let question_clone = question.clone();
2542        self.with_state("query_senior.suspend", move |s| {
2543            let task = s
2544                .tasks
2545                .get_mut(&task_id_inner)
2546                .ok_or_else(|| EngineError::TaskNotFound(task_id_inner.to_string()))?;
2547            task.status = TaskStatus::Suspended;
2548            task.suspended_on = Some(key_clone.clone());
2549            task.updated_at = now_unix();
2550            s.pending_resumes
2551                .insert(key_clone.clone(), ResumePending::new());
2552            s.push_event(Event::SeniorQueried {
2553                task_id: task_id_inner.clone(),
2554                question: question_clone.clone(),
2555            });
2556            s.push_event(Event::TaskSuspended {
2557                task_id: task_id_inner.clone(),
2558                key: key_clone.clone(),
2559            });
2560            Ok::<(), EngineError>(())
2561        })
2562        .await??;
2563
2564        // Notify callers waiting for a task status change (Running → Suspended).
2565        task_notify.notify_waiters();
2566
2567        let _ = self
2568            .inner
2569            .event_tx
2570            .send(Event::SeniorQueried { task_id, question });
2571        Ok(key)
2572    }
2573
2574    /// Store the answer for a `ResumeKey` in `EngineState` and wake the
2575    /// waiting caller via `Notify`. Also flips the suspended task's
2576    /// status back to `Running` and fires the per-task notifier.
2577    pub async fn resume(&self, key: ResumeKey, answer: Value) -> Result<(), EngineError> {
2578        let answer_for_state = answer.clone();
2579        let answer_for_event = answer.clone();
2580        let key_clone = key.clone();
2581        let (notify, task_notify, task_id_opt) = self
2582            .with_state("resume.set", move |s| {
2583                let pending = s
2584                    .pending_resumes
2585                    .get_mut(&key_clone)
2586                    .ok_or(EngineError::ResumeKeyNotFound)?;
2587                pending.answer = Some(answer_for_state);
2588                let notify = pending.notify.clone();
2589
2590                let task_id = s
2591                    .tasks
2592                    .iter()
2593                    .find(|(_, t)| t.suspended_on.as_ref() == Some(&key_clone))
2594                    .map(|(id, _)| id.clone());
2595
2596                let task_notify = task_id.as_ref().map(|tid| s.ensure_task_notify(tid));
2597
2598                if let Some(tid) = &task_id {
2599                    if let Some(task) = s.tasks.get_mut(tid) {
2600                        task.suspended_on = None;
2601                        task.status = TaskStatus::Running;
2602                        task.updated_at = now_unix();
2603                    }
2604                    s.push_event(Event::TaskResumed {
2605                        task_id: tid.clone(),
2606                        key: key_clone.clone(),
2607                    });
2608                    s.push_event(Event::SeniorAnswered {
2609                        task_id: tid.clone(),
2610                        answer: answer_for_event.clone(),
2611                    });
2612                }
2613                Ok::<_, EngineError>((notify, task_notify, task_id))
2614            })
2615            .await??;
2616
2617        // Outside the lock: notify_waiters for both the ResumePending and task-status waits.
2618        notify.notify_waiters();
2619        if let Some(n) = task_notify {
2620            n.notify_waiters();
2621        }
2622
2623        if let Some(tid) = task_id_opt {
2624            let _ = self
2625                .inner
2626                .event_tx
2627                .send(Event::TaskResumed { task_id: tid, key });
2628        }
2629        Ok(())
2630    }
2631
2632    /// Wait for the resume answer. Even if the caller (an Operator)
2633    /// detached and reattached, the answer is available immediately here
2634    /// — if it was already stored, this returns without waiting on the
2635    /// notifier.
2636    ///
2637    /// `timeout = Duration::ZERO` performs an instant check without
2638    /// waiting.
2639    pub async fn await_resume(
2640        &self,
2641        key: ResumeKey,
2642        timeout: Duration,
2643    ) -> Result<Value, EngineError> {
2644        // (1) Under the lock: clone the notify handle and check for an existing answer.
2645        let key_clone = key.clone();
2646        let (notify, existing) = self
2647            .with_state("await_resume.snapshot", move |s| {
2648                let pending = s
2649                    .pending_resumes
2650                    .get(&key_clone)
2651                    .ok_or(EngineError::ResumeKeyNotFound)?;
2652                Ok::<_, EngineError>((pending.notify.clone(), pending.answer.clone()))
2653            })
2654            .await??;
2655
2656        // (2) If an answer has already been stored, return immediately (detach / reattach pattern).
2657        if let Some(v) = existing {
2658            return Ok(v);
2659        }
2660
2661        // (3) Outside the lock: wait on the notify with a timeout.
2662        if timeout.is_zero() {
2663            return Err(EngineError::PollTimeout);
2664        }
2665        let waited = tokio::time::timeout(timeout, notify.notified()).await;
2666        if waited.is_err() {
2667            return Err(EngineError::PollTimeout);
2668        }
2669
2670        // (4) Under the lock: re-read the answer (should be present now that we were notified).
2671        let key_clone = key.clone();
2672        self.with_state("await_resume.read", move |s| {
2673            let pending = s
2674                .pending_resumes
2675                .get(&key_clone)
2676                .ok_or(EngineError::ResumeKeyNotFound)?;
2677            pending
2678                .answer
2679                .clone()
2680                .ok_or_else(|| EngineError::Internal("notified but answer missing".into()))
2681        })
2682        .await?
2683    }
2684
2685    // ═══════════════════════════════════════════════════════════════════════
2686    // poll_task — the "wait" path that waits for task-status changes (works for long-poll and regular wait).
2687    // ═══════════════════════════════════════════════════════════════════════
2688
2689    /// Wait until the task's status **transitions to terminal or
2690    /// `Suspended`**, then return the latest `TaskState`. Returns
2691    /// immediately if the task is already in a terminal state.
2692    /// Exceeding the timeout returns `EngineError::PollTimeout`.
2693    ///
2694    /// A `hold` of `Duration::from_secs(0)` returns a snapshot immediately
2695    /// (no wait). Larger holds — tens of minutes up to days — are fine;
2696    /// the wait state is kept in memory inside the engine and does not
2697    /// degrade.
2698    pub async fn poll_task(
2699        &self,
2700        token: &CapToken,
2701        task_id: &StepId,
2702        hold: Duration,
2703    ) -> Result<TaskState, EngineError> {
2704        self.verify_token_for_task(token, Verb::PollTask, task_id)
2705            .await?;
2706        let task_id_inner = task_id.clone();
2707
2708        // (1) Under the lock: take a snapshot and clone task_notify.
2709        let (state, notify) = self
2710            .with_state("poll_task.snapshot", move |s| {
2711                let task = s
2712                    .tasks
2713                    .get(&task_id_inner)
2714                    .cloned()
2715                    .ok_or_else(|| EngineError::TaskNotFound(task_id_inner.to_string()))?;
2716                let notify = s.ensure_task_notify(&task_id_inner);
2717                Ok::<_, EngineError>((task, notify))
2718            })
2719            .await??;
2720
2721        // (2) Immediate-return condition: already terminal / Suspended (nothing left to wait on).
2722        if matches!(
2723            state.status,
2724            TaskStatus::Pass | TaskStatus::Blocked | TaskStatus::Cancelled | TaskStatus::Suspended
2725        ) {
2726            return Ok(state);
2727        }
2728        if hold.is_zero() {
2729            return Ok(state);
2730        }
2731
2732        // (3) Outside the lock: wait on Notify with a timeout.
2733        let waited = tokio::time::timeout(hold, notify.notified()).await;
2734        if waited.is_err() {
2735            return Err(EngineError::PollTimeout);
2736        }
2737
2738        // (4) Under the lock: take a fresh snapshot.
2739        let task_id_inner = task_id.clone();
2740        self.with_state("poll_task.reread", move |s| {
2741            s.tasks
2742                .get(&task_id_inner)
2743                .cloned()
2744                .ok_or_else(|| EngineError::TaskNotFound(task_id_inner.to_string()))
2745        })
2746        .await?
2747    }
2748
2749    // ═══════════════════════════════════════════════════════════════════════
2750    // Background: heartbeat miss → detach loop
2751    // ═══════════════════════════════════════════════════════════════════════
2752
2753    /// Background loop that scans sessions every `heartbeat_interval` and
2754    /// flips `attached = false` on any session whose `last_seen` exceeds
2755    /// `heartbeat_miss_threshold * interval`.
2756    ///
2757    /// The tasks themselves are kept (assuming
2758    /// `keepalive_on_idle = true`), so another client can reattach with
2759    /// the same token and resume immediately. Dropping the returned
2760    /// `JoinHandle` does not stop the loop — the handle exists so callers
2761    /// who want to abort can hold onto it.
2762    pub fn start_detach_loop(&self) -> tokio::task::JoinHandle<()> {
2763        let engine = self.clone();
2764        let cfg = self.inner.cfg.long_hold.clone();
2765        let interval = cfg.heartbeat_interval;
2766        let miss_secs = cfg.heartbeat_interval.as_secs() * cfg.heartbeat_miss_threshold as u64;
2767
2768        tokio::spawn(async move {
2769            let mut ticker = tokio::time::interval(interval);
2770            ticker.tick().await; // first tick is immediate
2771            loop {
2772                ticker.tick().await;
2773                let now = now_unix();
2774                let detached = engine
2775                    .with_state("detach_loop.scan", |s| {
2776                        let mut detached = Vec::new();
2777                        for (sid, sess) in s.sessions.iter_mut() {
2778                            if !sess.attached {
2779                                continue;
2780                            }
2781                            if now.saturating_sub(sess.last_seen) >= miss_secs {
2782                                sess.attached = false;
2783                                detached.push(sid.clone());
2784                            }
2785                        }
2786                        for sid in &detached {
2787                            s.push_event(Event::SessionDetached {
2788                                session_id: sid.clone(),
2789                            });
2790                        }
2791                        detached
2792                    })
2793                    .await
2794                    .unwrap_or_default();
2795                for sid in detached {
2796                    let _ = engine
2797                        .inner
2798                        .event_tx
2799                        .send(Event::SessionDetached { session_id: sid });
2800                }
2801            }
2802        })
2803    }
2804
2805    /// Helper: wake a task whose status has changed. Called from the
2806    /// method body outside the lock.
2807    async fn wake_task(&self, task_id: &StepId) -> Result<(), EngineError> {
2808        let task_id = task_id.clone();
2809        let notify_opt = self
2810            .with_state("wake_task.get_notify", move |s| {
2811                s.task_notifies.get(&task_id).cloned()
2812            })
2813            .await?;
2814        if let Some(n) = notify_opt {
2815            n.notify_waiters();
2816        }
2817        Ok(())
2818    }
2819}
2820
2821/// Decide what a submit-time projection sink should do at a fail-open
2822/// branch given the configured [`crate::core::config::CheckPolicy`].
2823///
2824/// Returns `Ok(())` under [`CheckPolicy::Silent`] and
2825/// [`CheckPolicy::Warn`] — the caller continues with fail-open. Returns
2826/// [`EngineError::CheckPolicyStrict`] under [`CheckPolicy::Strict`],
2827/// carrying the caller-supplied `context` (call-site identifier) and
2828/// `message` (the pre-existing warn-log message literal, preserved
2829/// verbatim for log parse compatibility).
2830///
2831/// This helper deliberately does **not** call `tracing::warn!` itself —
2832/// the caller is responsible for firing the existing warn! (with its
2833/// full structured-field payload — `%task_id`, `agent`, `canonical`,
2834/// `error`, etc.) under `Warn` mode, and for skipping the warn! under
2835/// `Silent` mode. Keeping the warn! at the call site preserves the
2836/// exact structured-field shape every existing log-parse consumer sees;
2837/// forwarding it through the helper would either drop those fields or
2838/// require a macro (deferred, see subtask-1b).
2839///
2840/// Design intent: the fail-open discipline of every submit-time
2841/// projection sink is byte-identical to the pre-`CheckPolicy` behaviour
2842/// under the default [`CheckPolicy::Warn`]. `Silent` is a per-run opt-in
2843/// to suppress noise (e.g., a caller that has already verified upstream
2844/// invariants); `Strict` is a per-run opt-in to fail loudly (e.g., a
2845/// caller that requires all parts to materialize). See
2846/// [`crate::core::config::CheckPolicy`] for the "state dirty on fail"
2847/// semantics of `Strict`.
2848pub(crate) fn apply_check_policy(
2849    policy: crate::core::config::CheckPolicy,
2850    context: &str,
2851    message: &str,
2852) -> Result<(), EngineError> {
2853    match policy {
2854        crate::core::config::CheckPolicy::Silent | crate::core::config::CheckPolicy::Warn => Ok(()),
2855        crate::core::config::CheckPolicy::Strict => Err(EngineError::CheckPolicyStrict {
2856            context: context.to_string(),
2857            message: message.to_string(),
2858        }),
2859    }
2860}
2861
2862#[cfg(test)]
2863mod check_policy_helper_tests {
2864    use super::apply_check_policy;
2865    use crate::core::config::CheckPolicy;
2866    use crate::core::errors::EngineError;
2867
2868    /// `Silent` returns `Ok(())` without producing an error. Log
2869    /// suppression (the "no `tracing::warn!`" half of the semantics) is
2870    /// enforced at the call site, not inside the helper — see the
2871    /// helper's doc comment for why.
2872    #[test]
2873    fn silent_returns_ok() {
2874        let result = apply_check_policy(CheckPolicy::Silent, "call/site", "sink message");
2875        assert!(matches!(result, Ok(())));
2876    }
2877
2878    /// `Warn` (the default) returns `Ok(())` — the caller continues
2879    /// with fail-open, having already fired its own `tracing::warn!`
2880    /// with the full structured-field payload.
2881    #[test]
2882    fn warn_returns_ok() {
2883        let result = apply_check_policy(CheckPolicy::Warn, "call/site", "sink message");
2884        assert!(matches!(result, Ok(())));
2885    }
2886
2887    /// `Strict` returns
2888    /// [`EngineError::CheckPolicyStrict`] with `context` and `message`
2889    /// copied verbatim from the caller — the completion route surfaces
2890    /// this as a step / launch error so a caller that has opted in can
2891    /// fail fast instead of proceeding with a partially-realized
2892    /// submission.
2893    #[test]
2894    fn strict_returns_error_with_context_and_message() {
2895        let result = apply_check_policy(
2896            CheckPolicy::Strict,
2897            "submit-time projection sink: file materialize",
2898            "no work_dir/project_root resolved; skipping file materialize (fail-open)",
2899        );
2900        match result {
2901            Err(EngineError::CheckPolicyStrict { context, message }) => {
2902                assert_eq!(context, "submit-time projection sink: file materialize");
2903                assert_eq!(
2904                    message,
2905                    "no work_dir/project_root resolved; skipping file materialize (fail-open)"
2906                );
2907            }
2908            other => panic!("expected CheckPolicyStrict, got {:?}", other),
2909        }
2910    }
2911}
2912
2913// ─── UT: issue #14 — token store keyed by fingerprint, not nonce ────────────
2914#[cfg(test)]
2915mod token_fingerprint_store_tests {
2916    use super::*;
2917
2918    /// A token that was never attached fails verify with a `TokenNotFound`
2919    /// that carries the fingerprint — never the nonce. The error string can
2920    /// surface in HTTP error bodies, so this is the secret-hygiene contract.
2921    #[tokio::test]
2922    async fn verify_unknown_token_reports_fingerprint_not_nonce() {
2923        let engine = Engine::new(EngineCfg::default());
2924        // Signed by the engine's own signer (sig passes) but never inserted
2925        // into the store — verify must fail at step (4), the store lookup.
2926        let token = engine.signer().session(
2927            "ghost",
2928            Role::Operator,
2929            vec!["*".into()],
2930            Duration::from_secs(60),
2931        );
2932        let err = engine
2933            .verify_token(&token, Verb::ReadTaskState)
2934            .await
2935            .expect_err("token is not in the store");
2936        let msg = err.to_string();
2937        assert!(
2938            msg.contains(&token.fingerprint()),
2939            "error must carry the fingerprint: {msg}"
2940        );
2941        assert!(
2942            !msg.contains(&token.nonce),
2943            "error must not leak the nonce: {msg}"
2944        );
2945    }
2946
2947    /// attach → verify → heartbeat → detach all resolve the session /
2948    /// token record through fingerprint keys (mint/verify lifecycle
2949    /// regression guard for the issue #14 key migration).
2950    #[tokio::test]
2951    async fn attach_verify_heartbeat_detach_cycle_with_fp_keying() {
2952        let engine = Engine::new(EngineCfg::default());
2953        let token = engine
2954            .attach("op-1", Role::Operator, Duration::from_secs(60))
2955            .await
2956            .expect("attach");
2957        engine
2958            .verify_token(&token, Verb::ReadTaskState)
2959            .await
2960            .expect("verify consumes via fp key");
2961        engine
2962            .heartbeat(&token)
2963            .await
2964            .expect("heartbeat finds the session by fp");
2965        engine
2966            .detach(&token)
2967            .await
2968            .expect("detach finds the session by fp");
2969    }
2970}
2971
2972// ─── UT: `OperatorKind` "Runtime Global" tier — `Option` semantics ─────────
2973//
2974// Regression coverage for the "explicit Automate is indistinguishable from
2975// unspecified" defect: `OperatorSession.operator_kind` (and the
2976// `attach_with_ids` `kind` parameter it stores) is `Option<OperatorKind>`,
2977// so `Some(Automate)` is an explicit Runtime Global request that must
2978// outrank `bp_global`, while `None` must let `bp_global` decide. Exercises
2979// the real `resolve_operator_info` cascade path (not just
2980// `collapse_operator_kind` in isolation), attaching via `attach_with_ids`
2981// exactly as `TaskLaunchService::launch` does.
2982#[cfg(test)]
2983mod resolve_operator_info_runtime_global_tests {
2984    use super::*;
2985
2986    async fn attach_and_resolve(
2987        runtime_global: Option<OperatorKind>,
2988        bp_global: Option<OperatorKind>,
2989    ) -> OperatorInfo {
2990        let engine = Engine::new(EngineCfg::default());
2991        let token = engine
2992            .attach_with_ids(
2993                "ut-op",
2994                Role::Operator,
2995                Duration::from_secs(30),
2996                runtime_global,
2997                None,
2998                None,
2999                None,
3000                HashMap::new(),
3001                HashMap::new(),
3002                bp_global,
3003            )
3004            .await
3005            .expect("attach_with_ids ok");
3006        let session = engine
3007            .with_state("test.find_session", |s| {
3008                s.sessions
3009                    .values()
3010                    .find(|sess| sess.token_fp == token.fingerprint())
3011                    .cloned()
3012            })
3013            .await
3014            .expect("with_state ok")
3015            .expect("session present after attach_with_ids");
3016        engine.resolve_operator_info(&session, "agent-x").await
3017    }
3018
3019    #[tokio::test]
3020    async fn explicit_some_automate_outranks_bp_global_main_ai() {
3021        // Runtime Global explicitly requests Automate; bp_global is MainAi.
3022        // The explicit `Some(Automate)` must win — this is exactly the case
3023        // the old `== OperatorKind::default()` convention got wrong (it
3024        // could not tell "explicitly Automate" from "unspecified" and would
3025        // have let `bp_global` (MainAi) take over instead).
3026        let info =
3027            attach_and_resolve(Some(OperatorKind::Automate), Some(OperatorKind::MainAi)).await;
3028        assert_eq!(
3029            info.kind,
3030            OperatorKind::Automate,
3031            "explicit Some(Automate) runtime_global must outrank bp_global MainAi"
3032        );
3033    }
3034
3035    #[tokio::test]
3036    async fn none_lets_bp_global_main_ai_win() {
3037        // Runtime Global left unspecified (`None`); bp_global is MainAi.
3038        // With nothing more specific set, `bp_global` must decide.
3039        let info = attach_and_resolve(None, Some(OperatorKind::MainAi)).await;
3040        assert_eq!(
3041            info.kind,
3042            OperatorKind::MainAi,
3043            "None runtime_global must let bp_global MainAi win"
3044        );
3045    }
3046}
3047
3048/// issue #13 run_id propagation: `dispatch_attempt_with`'s `run_id` param
3049/// must land in `Ctx.meta.runtime["run_id"]` (the same slot pattern as the
3050/// pre-existing `worker_handle`), or be omitted entirely when `None`. Same
3051/// `CtxProbe` shape as `middleware::worker_binding`'s test module — an
3052/// inner `SpawnerAdapter` that snapshots the `Ctx` it was called with and
3053/// fails the spawn (only the ctx snapshot matters here).
3054#[cfg(test)]
3055mod dispatch_attempt_with_run_id_tests {
3056    use super::*;
3057    use crate::worker::adapter::{SpawnError, SpawnerAdapter};
3058    use crate::worker::Worker;
3059    use std::sync::Mutex as StdMutex;
3060
3061    struct CtxProbe {
3062        seen: Arc<StdMutex<Option<Ctx>>>,
3063    }
3064
3065    #[async_trait::async_trait]
3066    impl SpawnerAdapter for CtxProbe {
3067        async fn spawn(
3068            &self,
3069            _engine: &Engine,
3070            ctx: &Ctx,
3071            _task_id: StepId,
3072            _attempt: u32,
3073            _token: CapToken,
3074        ) -> Result<Box<dyn Worker>, SpawnError> {
3075            *self.seen.lock().unwrap() = Some(ctx.clone());
3076            Err(SpawnError::Internal("probe stop".into()))
3077        }
3078    }
3079
3080    async fn dispatch_with_probe(run_id: Option<&RunId>) -> Ctx {
3081        let engine = Engine::new(EngineCfg::default());
3082        let token = engine
3083            .attach("ut-op", Role::Operator, Duration::from_secs(30))
3084            .await
3085            .expect("attach");
3086        let tid = engine
3087            .start_task(
3088                &token,
3089                TaskSpec {
3090                    agent: "probe".into(),
3091                    initial_directive: "hi".into(),
3092                    step_ctx: None,
3093                    check_policy: None,
3094                },
3095            )
3096            .await
3097            .expect("start_task");
3098        let seen: Arc<StdMutex<Option<Ctx>>> = Arc::new(StdMutex::new(None));
3099        let spawner: Arc<dyn SpawnerAdapter> = Arc::new(CtxProbe { seen: seen.clone() });
3100        // The probe always errors the spawn (`SpawnError::Internal`); we
3101        // only care about the `Ctx` snapshot it captured, so the dispatch
3102        // outcome itself (`Err`) is discarded.
3103        let _ = engine
3104            .dispatch_attempt_with(&token, &tid, &spawner, run_id)
3105            .await;
3106        let captured = seen.lock().unwrap().clone();
3107        captured.expect("inner ctx captured")
3108    }
3109
3110    #[tokio::test]
3111    async fn run_id_lands_in_ctx_meta_runtime_when_some() {
3112        let run_id = RunId::new();
3113        let observed = dispatch_with_probe(Some(&run_id)).await;
3114        assert_eq!(
3115            observed.meta.runtime.get("run_id").and_then(|v| v.as_str()),
3116            Some(run_id.as_str()),
3117            "ctx.meta.runtime[\"run_id\"] must carry the run_id passed to dispatch_attempt_with"
3118        );
3119    }
3120
3121    #[tokio::test]
3122    async fn run_id_key_absent_when_none() {
3123        let observed = dispatch_with_probe(None).await;
3124        assert!(
3125            !observed.meta.runtime.contains_key("run_id"),
3126            "no run_id key must be injected when dispatch_attempt_with is called with None"
3127        );
3128    }
3129}
3130
3131/// GH #21 Phase 2: `TaskSpec.step_ctx` must land in
3132/// `Ctx.meta.runtime[STEP_CTX_KEY]` — re-read from the spec on EVERY
3133/// attempt (the prep closure re-reads `task.spec.step_ctx` every call, not
3134/// caching it once at `start_task`), so a retry (attempt 2) carries it
3135/// too. Same `CtxProbe` shape as `dispatch_attempt_with_run_id_tests`.
3136#[cfg(test)]
3137mod dispatch_attempt_with_step_ctx_tests {
3138    use super::*;
3139    use crate::worker::adapter::{SpawnError, SpawnerAdapter};
3140    use crate::worker::Worker;
3141    use std::sync::Mutex as StdMutex;
3142
3143    struct CtxProbe {
3144        seen: Arc<StdMutex<Option<Ctx>>>,
3145    }
3146
3147    #[async_trait::async_trait]
3148    impl SpawnerAdapter for CtxProbe {
3149        async fn spawn(
3150            &self,
3151            _engine: &Engine,
3152            ctx: &Ctx,
3153            _task_id: StepId,
3154            _attempt: u32,
3155            _token: CapToken,
3156        ) -> Result<Box<dyn Worker>, SpawnError> {
3157            *self.seen.lock().unwrap() = Some(ctx.clone());
3158            Err(SpawnError::Internal("probe stop".into()))
3159        }
3160    }
3161
3162    #[tokio::test]
3163    async fn step_ctx_lands_in_ctx_meta_runtime_on_attempt_1_and_2() {
3164        let engine = Engine::new(EngineCfg::default());
3165        let token = engine
3166            .attach("ut-op", Role::Operator, Duration::from_secs(30))
3167            .await
3168            .expect("attach");
3169        let tid = engine
3170            .start_task(
3171                &token,
3172                TaskSpec {
3173                    agent: "probe".into(),
3174                    initial_directive: "hi".into(),
3175                    step_ctx: Some(serde_json::json!({ "work_dir": "/step" })),
3176                    check_policy: None,
3177                },
3178            )
3179            .await
3180            .expect("start_task");
3181        let seen: Arc<StdMutex<Option<Ctx>>> = Arc::new(StdMutex::new(None));
3182        let spawner: Arc<dyn SpawnerAdapter> = Arc::new(CtxProbe { seen: seen.clone() });
3183
3184        // The probe always errors the spawn; only the ctx snapshot matters.
3185        let _ = engine
3186            .dispatch_attempt_with(&token, &tid, &spawner, None)
3187            .await;
3188        let first = seen
3189            .lock()
3190            .unwrap()
3191            .clone()
3192            .expect("attempt 1 ctx captured");
3193        assert_eq!(
3194            first.meta.runtime.get(STEP_CTX_KEY),
3195            Some(&serde_json::json!({ "work_dir": "/step" })),
3196            "attempt 1 must carry TaskSpec.step_ctx in ctx.meta.runtime[STEP_CTX_KEY]"
3197        );
3198
3199        let _ = engine
3200            .dispatch_attempt_with(&token, &tid, &spawner, None)
3201            .await;
3202        let second = seen
3203            .lock()
3204            .unwrap()
3205            .clone()
3206            .expect("attempt 2 ctx captured");
3207        assert_eq!(
3208            second.meta.runtime.get(STEP_CTX_KEY),
3209            Some(&serde_json::json!({ "work_dir": "/step" })),
3210            "attempt 2 (retry) must ALSO carry TaskSpec.step_ctx — prep re-reads the spec every attempt"
3211        );
3212    }
3213
3214    #[tokio::test]
3215    async fn step_ctx_key_absent_when_none() {
3216        let engine = Engine::new(EngineCfg::default());
3217        let token = engine
3218            .attach("ut-op", Role::Operator, Duration::from_secs(30))
3219            .await
3220            .expect("attach");
3221        let tid = engine
3222            .start_task(
3223                &token,
3224                TaskSpec {
3225                    agent: "probe".into(),
3226                    initial_directive: "hi".into(),
3227                    step_ctx: None,
3228                    check_policy: None,
3229                },
3230            )
3231            .await
3232            .expect("start_task");
3233        let seen: Arc<StdMutex<Option<Ctx>>> = Arc::new(StdMutex::new(None));
3234        let spawner: Arc<dyn SpawnerAdapter> = Arc::new(CtxProbe { seen: seen.clone() });
3235        let _ = engine
3236            .dispatch_attempt_with(&token, &tid, &spawner, None)
3237            .await;
3238        let observed = seen.lock().unwrap().clone().expect("ctx captured");
3239        assert!(
3240            !observed.meta.runtime.contains_key(STEP_CTX_KEY),
3241            "no step_ctx key must be injected when TaskSpec.step_ctx is None"
3242        );
3243    }
3244}
3245
3246// ─── issue #18: `TaskSpec.initial_directive` `Value` pass-through ──────────
3247#[cfg(test)]
3248mod initial_directive_value_passthrough_tests {
3249    use super::*;
3250
3251    async fn seeded_engine(initial_directive: Value) -> (Engine, CapToken, StepId) {
3252        let engine = Engine::new(EngineCfg::default());
3253        let op_token = engine
3254            .attach("ut-op", Role::Operator, Duration::from_secs(30))
3255            .await
3256            .expect("attach");
3257        let task_id = engine
3258            .start_task(
3259                &op_token,
3260                TaskSpec {
3261                    agent: "planner".to_string(),
3262                    initial_directive,
3263                    step_ctx: None,
3264                    check_policy: None,
3265                },
3266            )
3267            .await
3268            .expect("start_task");
3269        (engine, op_token, task_id)
3270    }
3271
3272    /// Mint + register a `Role::Worker` token the same way
3273    /// `dispatch_attempt_with` does — `fetch_prompt` is worker-verb-gated.
3274    async fn mint_worker_token(engine: &Engine, task_id: &StepId) -> CapToken {
3275        let worker_token = engine.signer().session(
3276            format!("worker-of-{task_id}"),
3277            Role::Worker,
3278            vec!["*".into()],
3279            Duration::from_secs(600),
3280        );
3281        let fp = worker_token.fingerprint();
3282        let record = CapTokenRecord::from_worker_token(worker_token.clone(), task_id.clone());
3283        engine
3284            .with_state("test.mint_worker", move |s| {
3285                s.tokens.insert(fp, record);
3286            })
3287            .await
3288            .expect("mint worker token");
3289        worker_token
3290    }
3291
3292    /// `EngineDispatcher::dispatch` no longer stringifies the evaluated
3293    /// `Step.in` value before seeding `TaskSpec.initial_directive` — an
3294    /// Object seed must round-trip through `start_task` /
3295    /// `read_task_state` byte-for-byte as the same `Value::Object`, not a
3296    /// JSON-stringified `Value::String`.
3297    #[tokio::test]
3298    async fn object_seed_passes_through_task_spec_unchanged() {
3299        let seed = serde_json::json!({"key": "value"});
3300        let (engine, token, task_id) = seeded_engine(seed.clone()).await;
3301        let state = engine
3302            .read_task_state(&token, &task_id)
3303            .await
3304            .expect("read_task_state");
3305        assert_eq!(
3306            state.spec.initial_directive, seed,
3307            "TaskSpec.initial_directive must equal the raw Object seed, not a stringified copy"
3308        );
3309    }
3310
3311    /// `Engine::fetch_prompt` returns the `Value` end-to-end (issue #18):
3312    /// an Object seed stays a `Value::Object` and is not stringified in
3313    /// the engine layer. The Worker HTTP boundary
3314    /// (`fetch_worker_payload*`) is what performs the render down to a
3315    /// JSON literal `String` for `WorkerPayload.prompt`.
3316    #[tokio::test]
3317    async fn object_seed_passes_through_fetch_prompt_as_value() {
3318        let seed = serde_json::json!({"key": "value"});
3319        let (engine, _token, task_id) = seeded_engine(seed.clone()).await;
3320        let worker_token = mint_worker_token(&engine, &task_id).await;
3321        let prompt = engine
3322            .fetch_prompt(&worker_token, &task_id)
3323            .await
3324            .expect("fetch_prompt");
3325        assert_eq!(
3326            prompt, seed,
3327            "fetch_prompt must return the raw Object Value, not a stringified copy"
3328        );
3329    }
3330
3331    /// The Worker HTTP boundary is the render point: `fetch_worker_payload*`
3332    /// coerces the stored `Value` down to `WorkerPayload.prompt: String`
3333    /// (JSON-literal shape for non-strings). Verifies the boundary render
3334    /// stays intact for an Object seed.
3335    #[tokio::test]
3336    async fn object_seed_renders_as_json_literal_at_worker_payload_boundary() {
3337        let seed = serde_json::json!({"key": "value"});
3338        let (engine, _token, task_id) = seeded_engine(seed).await;
3339        let worker_token = mint_worker_token(&engine, &task_id).await;
3340        let payload = engine
3341            .fetch_worker_payload(&worker_token, &task_id)
3342            .await
3343            .expect("fetch_worker_payload");
3344        assert_eq!(
3345            payload.prompt, r#"{"key":"value"}"#,
3346            "WorkerPayload.prompt must be the JSON literal String render of the Value seed"
3347        );
3348    }
3349
3350    /// A `String` seed is unaffected — still passes through verbatim, both
3351    /// as the `TaskSpec.initial_directive` `Value` and as the Worker
3352    /// `fetch_prompt` return (issue #18 Invariant 2).
3353    #[tokio::test]
3354    async fn string_seed_passes_through_unchanged() {
3355        let (engine, token, task_id) = seeded_engine(serde_json::json!("do the thing")).await;
3356        let state = engine
3357            .read_task_state(&token, &task_id)
3358            .await
3359            .expect("read_task_state");
3360        assert_eq!(
3361            state.spec.initial_directive,
3362            serde_json::json!("do the thing")
3363        );
3364        let worker_token = mint_worker_token(&engine, &task_id).await;
3365        let prompt = engine
3366            .fetch_prompt(&worker_token, &task_id)
3367            .await
3368            .expect("fetch_prompt");
3369        assert_eq!(prompt, serde_json::json!("do the thing"));
3370    }
3371}
3372
3373/// GH #31: `fetch_worker_payload{,_trusted}`'s size-threshold branch
3374/// between inline (`WorkerPayload.system`) and by-reference
3375/// (`WorkerPayload.system_ref`) delivery, plus the `bake_worker_system_prompt`
3376/// `agent_render_sizes` bookkeeping that feeds `agent_last_rendered_size`.
3377#[cfg(test)]
3378mod system_ref_threshold_tests {
3379    use super::*;
3380
3381    async fn seeded_engine_with_cfg(cfg: EngineCfg) -> (Engine, CapToken, StepId) {
3382        let engine = Engine::new(cfg);
3383        let op_token = engine
3384            .attach("ut-op", Role::Operator, Duration::from_secs(30))
3385            .await
3386            .expect("attach");
3387        let task_id = engine
3388            .start_task(
3389                &op_token,
3390                TaskSpec {
3391                    agent: "planner".to_string(),
3392                    initial_directive: serde_json::json!("do the thing"),
3393                    step_ctx: None,
3394                    check_policy: None,
3395                },
3396            )
3397            .await
3398            .expect("start_task");
3399        (engine, op_token, task_id)
3400    }
3401
3402    /// Same worker-token-minting fixture as
3403    /// `initial_directive_value_passthrough_tests::mint_worker_token`
3404    /// (kept local to this module — the two `mod`s do not share private
3405    /// helpers across `cfg(test)` boundaries).
3406    async fn mint_worker_token(engine: &Engine, task_id: &StepId) -> CapToken {
3407        let worker_token = engine.signer().session(
3408            format!("worker-of-{task_id}"),
3409            Role::Worker,
3410            vec!["*".into()],
3411            Duration::from_secs(600),
3412        );
3413        let fp = worker_token.fingerprint();
3414        let record = CapTokenRecord::from_worker_token(worker_token.clone(), task_id.clone());
3415        engine
3416            .with_state("test.mint_worker", move |s| {
3417                s.tokens.insert(fp, record);
3418            })
3419            .await
3420            .expect("mint worker token");
3421        worker_token
3422    }
3423
3424    /// Under-threshold: `system` stays inline, `system_ref` stays `None`.
3425    #[tokio::test]
3426    async fn under_threshold_stays_inline() {
3427        let (engine, _op_token, task_id) = seeded_engine_with_cfg(EngineCfg::default()).await;
3428        let worker_token = mint_worker_token(&engine, &task_id).await;
3429        let rendered = "a short system prompt".to_string();
3430        engine
3431            .bake_worker_system_prompt(&task_id, 1, Some(rendered.clone()))
3432            .await
3433            .expect("bake");
3434        let payload = engine
3435            .fetch_worker_payload(&worker_token, &task_id)
3436            .await
3437            .expect("fetch_worker_payload");
3438        assert_eq!(payload.system, Some(rendered));
3439        assert!(payload.system_ref.is_none());
3440    }
3441
3442    /// Over-threshold: `system` is cleared and `system_ref` is populated
3443    /// with a `sha256` matching the known input string. Exercises
3444    /// `fetch_worker_payload_trusted` (the `_trusted` sibling must be
3445    /// behaviorally identical to `fetch_worker_payload`).
3446    #[tokio::test]
3447    async fn over_threshold_switches_to_system_ref_with_matching_sha256() {
3448        let mut cfg = EngineCfg::default();
3449        cfg.system_ref.threshold_bytes = 16;
3450        cfg.system_ref.mode = crate::types::SystemRefMode::File;
3451        cfg.system_ref.store_dir =
3452            std::env::temp_dir().join(format!("mse-system-ref-test-{}", crate::types::now_unix()));
3453        let (engine, _op_token, task_id) = seeded_engine_with_cfg(cfg).await;
3454        let rendered =
3455            "this system prompt is deliberately longer than the 16 byte threshold".to_string();
3456        engine
3457            .bake_worker_system_prompt(&task_id, 1, Some(rendered.clone()))
3458            .await
3459            .expect("bake");
3460        let payload = engine
3461            .fetch_worker_payload_trusted(&task_id)
3462            .await
3463            .expect("fetch_worker_payload_trusted");
3464        assert!(
3465            payload.system.is_none(),
3466            "over-threshold response must not also inline `system`"
3467        );
3468        let system_ref = payload
3469            .system_ref
3470            .expect("over-threshold response must populate system_ref");
3471        assert_eq!(system_ref.size_bytes, rendered.len() as u64);
3472        assert_eq!(system_ref.mode, crate::types::SystemRefMode::File);
3473        use sha2::Digest;
3474        let expected_sha256 = hex::encode(sha2::Sha256::digest(rendered.as_bytes()));
3475        assert_eq!(system_ref.sha256, expected_sha256);
3476        assert!(system_ref.uri.starts_with("file://"));
3477        let written = tokio::fs::read_to_string(system_ref.uri.trim_start_matches("file://"))
3478            .await
3479            .expect("File mode must have written the referenced path");
3480        assert_eq!(written, rendered);
3481    }
3482
3483    /// `Http` mode never writes a file — `system_ref.uri` is the bare path
3484    /// the engine can construct on its own, scheme/host-free.
3485    #[tokio::test]
3486    async fn over_threshold_http_mode_constructs_path_only_uri() {
3487        let mut cfg = EngineCfg::default();
3488        cfg.system_ref.threshold_bytes = 16;
3489        cfg.system_ref.mode = crate::types::SystemRefMode::Http;
3490        let (engine, _op_token, task_id) = seeded_engine_with_cfg(cfg).await;
3491        let worker_token = mint_worker_token(&engine, &task_id).await;
3492        let rendered =
3493            "this system prompt is deliberately longer than the 16 byte threshold".to_string();
3494        engine
3495            .bake_worker_system_prompt(&task_id, 1, Some(rendered))
3496            .await
3497            .expect("bake");
3498        let payload = engine
3499            .fetch_worker_payload(&worker_token, &task_id)
3500            .await
3501            .expect("fetch_worker_payload");
3502        let system_ref = payload.system_ref.expect("system_ref must be populated");
3503        assert_eq!(system_ref.mode, crate::types::SystemRefMode::Http);
3504        assert_eq!(
3505            system_ref.uri,
3506            format!("/v1/worker/prompt/system?task_id={task_id}&attempt=1")
3507        );
3508    }
3509
3510    /// `bake_worker_system_prompt` records the render size keyed by agent
3511    /// name (last-write-wins), readable via `agent_last_rendered_size`.
3512    #[tokio::test]
3513    async fn bake_records_agent_render_size_last_write_wins() {
3514        let (engine, _op_token, task_id) = seeded_engine_with_cfg(EngineCfg::default()).await;
3515        assert_eq!(engine.agent_last_rendered_size("planner").await, None);
3516        engine
3517            .bake_worker_system_prompt(&task_id, 1, Some("a".repeat(10)))
3518            .await
3519            .expect("bake 1");
3520        assert_eq!(engine.agent_last_rendered_size("planner").await, Some(10));
3521        engine
3522            .bake_worker_system_prompt(&task_id, 2, Some("b".repeat(20)))
3523            .await
3524            .expect("bake 2");
3525        assert_eq!(
3526            engine.agent_last_rendered_size("planner").await,
3527            Some(20),
3528            "most-recently-observed size wins, not the largest"
3529        );
3530    }
3531}
3532
3533/// subtask-4 / ST2 rework: `submit_output` / `submit_worker_result_trusted`'s
3534/// submit-time projection sink (`Engine::materialize_final_submission`) —
3535/// the Data-plane `OutputStore` dual-write plus the
3536/// `FileProjectionAdapter`-backed file materialize, both fail-open. See
3537/// the subtask-4 Tests this module covers inline on each test.
3538#[cfg(test)]
3539mod submit_time_projection_sink_tests {
3540    use super::*;
3541    use crate::core::agent_context::AgentContextView;
3542    use crate::store::output::{ContentRef, InMemoryOutputStore, OutputEvent};
3543
3544    /// Starts a task under `agent`, returning `(engine, op_token, task_id,
3545    /// worker_token)` — same helper shape as the sibling test modules
3546    /// above (`initial_directive_value_passthrough_tests::seeded_engine` /
3547    /// `mint_worker_token`), duplicated locally per this file's
3548    /// established per-module convention.
3549    async fn seeded_task(agent: &str) -> (Engine, CapToken, StepId, CapToken) {
3550        let engine = Engine::new(EngineCfg::default());
3551        let op_token = engine
3552            .attach("ut-op", Role::Operator, Duration::from_secs(30))
3553            .await
3554            .expect("attach");
3555        let task_id = engine
3556            .start_task(
3557                &op_token,
3558                TaskSpec {
3559                    agent: agent.to_string(),
3560                    initial_directive: Value::String("go".into()),
3561                    step_ctx: None,
3562                    check_policy: None,
3563                },
3564            )
3565            .await
3566            .expect("start_task");
3567        let worker_token = engine.signer().session(
3568            format!("worker-of-{task_id}"),
3569            Role::Worker,
3570            vec!["*".into()],
3571            Duration::from_secs(600),
3572        );
3573        let fp = worker_token.fingerprint();
3574        let record = CapTokenRecord::from_worker_token(worker_token.clone(), task_id.clone());
3575        engine
3576            .with_state("test.mint_worker", move |s| {
3577                s.tokens.insert(fp, record);
3578            })
3579            .await
3580            .expect("mint worker token");
3581        (engine, op_token, task_id, worker_token)
3582    }
3583
3584    /// Sibling of [`seeded_task`] that lets a caller pin the engine's
3585    /// `EngineCfg.check_policy` before the engine is constructed — used
3586    /// by the `check_policy_*` regression tests below to exercise the
3587    /// three [`crate::core::config::CheckPolicy`] modes without touching
3588    /// the shared `seeded_task` helper (which every unrelated sink test
3589    /// depends on).
3590    async fn seeded_task_with_policy(
3591        agent: &str,
3592        policy: crate::core::config::CheckPolicy,
3593    ) -> (Engine, CapToken, StepId, CapToken) {
3594        let cfg = EngineCfg {
3595            check_policy: policy,
3596            ..EngineCfg::default()
3597        };
3598        let engine = Engine::new(cfg);
3599        let op_token = engine
3600            .attach("ut-op", Role::Operator, Duration::from_secs(30))
3601            .await
3602            .expect("attach");
3603        let task_id = engine
3604            .start_task(
3605                &op_token,
3606                TaskSpec {
3607                    agent: agent.to_string(),
3608                    initial_directive: Value::String("go".into()),
3609                    step_ctx: None,
3610                    check_policy: None,
3611                },
3612            )
3613            .await
3614            .expect("start_task");
3615        let worker_token = engine.signer().session(
3616            format!("worker-of-{task_id}"),
3617            Role::Worker,
3618            vec!["*".into()],
3619            Duration::from_secs(600),
3620        );
3621        let fp = worker_token.fingerprint();
3622        let record = CapTokenRecord::from_worker_token(worker_token.clone(), task_id.clone());
3623        engine
3624            .with_state("test.mint_worker", move |s| {
3625                s.tokens.insert(fp, record);
3626            })
3627            .await
3628            .expect("mint worker token");
3629        (engine, op_token, task_id, worker_token)
3630    }
3631
3632    /// Seeds `EngineState.agent_ctx[(task_id, attempt)].view` directly —
3633    /// the same snapshot `AgentContextMiddleware` writes at spawn time
3634    /// (see its module doc), stood up here without the full spawner
3635    /// stack so these tests can exercise `submit_output` in isolation.
3636    async fn seed_agent_context(engine: &Engine, task_id: &StepId, attempt: u32, work_dir: &str) {
3637        let task_id = task_id.clone();
3638        let work_dir = work_dir.to_string();
3639        engine
3640            .with_state("test.seed_agent_context", move |s| {
3641                s.agent_ctx.insert(
3642                    (task_id, attempt),
3643                    crate::core::state::AgentCtxEntry {
3644                        view: AgentContextView {
3645                            work_dir: Some(work_dir),
3646                            ..Default::default()
3647                        },
3648                        policy: Default::default(),
3649                    },
3650                );
3651            })
3652            .await
3653            .expect("seed agent_ctx");
3654    }
3655
3656    /// GH #27 (follow-up to #23): seeds `EngineState.agent_ctx` with an
3657    /// arbitrary `work_dir` / `project_root` pair (either may be `None`),
3658    /// unlike [`seed_agent_context`] (which only ever sets `work_dir`) —
3659    /// lets these tests exercise `ProjectionPlacement::resolve_root`'s
3660    /// fallback in both directions.
3661    async fn seed_agent_context_roots(
3662        engine: &Engine,
3663        task_id: &StepId,
3664        attempt: u32,
3665        work_dir: Option<&str>,
3666        project_root: Option<&str>,
3667    ) {
3668        let task_id = task_id.clone();
3669        let work_dir = work_dir.map(str::to_string);
3670        let project_root = project_root.map(str::to_string);
3671        engine
3672            .with_state("test.seed_agent_context_roots", move |s| {
3673                s.agent_ctx.insert(
3674                    (task_id, attempt),
3675                    crate::core::state::AgentCtxEntry {
3676                        view: AgentContextView {
3677                            work_dir,
3678                            project_root,
3679                            ..Default::default()
3680                        },
3681                        policy: Default::default(),
3682                    },
3683                );
3684            })
3685            .await
3686            .expect("seed agent_ctx");
3687    }
3688
3689    /// GH #27 (follow-up to #23): seeds `EngineState.projection_placements`
3690    /// directly — the same snapshot `EngineDispatcher::dispatch` stashes
3691    /// at dispatch time (mirroring [`seed_step_naming`]'s contract) — so
3692    /// these tests can exercise a declared `ProjectionPlacement` without
3693    /// driving a real `Compiler::compile`.
3694    async fn seed_projection_placement(
3695        engine: &Engine,
3696        task_id: &StepId,
3697        placement: crate::core::projection_placement::ProjectionPlacement,
3698    ) {
3699        let task_id = task_id.clone();
3700        let placement = Arc::new(placement);
3701        engine
3702            .with_state("test.seed_projection_placement", move |s| {
3703                s.projection_placements.insert(task_id, placement);
3704            })
3705            .await
3706            .expect("seed projection_placements");
3707    }
3708
3709    /// GH #23 subtask-2: builds a fixture
3710    /// [`crate::core::step_naming::StepNaming`] table declaring `producer`
3711    /// → `canonical` (`AgentMeta.projection_name`), then seeds it into
3712    /// `EngineState.step_namings` for `task_id` — the same snapshot
3713    /// `EngineDispatcher::dispatch` stashes at dispatch time
3714    /// (`blueprint.rs`'s "construct once, read many" contract), stood up
3715    /// here without the full Blueprint-compile path so these tests can
3716    /// exercise the canonical-sink resolution in isolation.
3717    async fn seed_step_naming(engine: &Engine, task_id: &StepId, producer: &str, canonical: &str) {
3718        use crate::blueprint::{
3719            current_schema_version, AgentDef, AgentKind, AgentMeta, Blueprint, BlueprintMetadata,
3720            CompilerHints, CompilerStrategy,
3721        };
3722        use crate::core::step_naming::StepNaming;
3723        use mlua_flow_ir::{Expr, Node};
3724
3725        let flow = Node::Step {
3726            ref_: producer.to_string(),
3727            in_: Expr::Path {
3728                at: "$.in".parse().expect("literal test path: $.in"),
3729            },
3730            out: Expr::Path {
3731                at: format!("$.{producer}_out")
3732                    .parse()
3733                    .expect("literal test path"),
3734            },
3735        };
3736        let bp = Blueprint {
3737            schema_version: current_schema_version(),
3738            id: "sink-canonical-ut".into(),
3739            flow,
3740            agents: vec![AgentDef {
3741                name: producer.to_string(),
3742                kind: AgentKind::RustFn,
3743                spec: serde_json::json!({ "fn_id": producer }),
3744                profile: None,
3745                meta: Some(AgentMeta {
3746                    projection_name: Some(canonical.to_string()),
3747                    ..Default::default()
3748                }),
3749                runner: None,
3750                runner_ref: None,
3751                verdict: None,
3752            }],
3753            operators: vec![],
3754            metas: vec![],
3755            hints: CompilerHints::default(),
3756            strategy: CompilerStrategy::default(),
3757            metadata: BlueprintMetadata::default(),
3758            spawner_hints: Default::default(),
3759            default_agent_kind: AgentKind::Operator,
3760            default_operator_kind: None,
3761            default_init_ctx: None,
3762            default_agent_ctx: None,
3763            default_context_policy: None,
3764            projection_placement: None,
3765            audits: vec![],
3766            degradation_policy: None,
3767            runners: vec![],
3768            default_runner: None,
3769            check_policy: None,
3770        };
3771        let (naming, warnings) = StepNaming::from_blueprint(&bp).expect("no collision");
3772        assert!(warnings.is_empty(), "single-step fixture has no collisions");
3773        let naming = Arc::new(naming);
3774        let task_id = task_id.clone();
3775        engine
3776            .with_state("test.seed_step_naming", move |s| {
3777                s.step_namings.insert(task_id, naming);
3778            })
3779            .await
3780            .expect("seed step_namings");
3781    }
3782
3783    fn final_event(value: Value, ok: bool) -> crate::worker::output::OutputEvent {
3784        crate::worker::output::OutputEvent::Final {
3785            content: crate::worker::output::ContentRef::Inline { value },
3786            ok,
3787        }
3788    }
3789
3790    /// Subtask 4 Test #2: `submit_output`'s `Final` writes
3791    /// `<root>/workspace/tasks/<task_id>/ctx/<agent>.md`, content matching
3792    /// the submitted value.
3793    #[tokio::test]
3794    async fn submit_output_final_materializes_file_when_work_dir_resolved() {
3795        let dir = tempfile::TempDir::new().unwrap();
3796        let (engine, _op, task_id, worker_token) = seeded_task("planner").await;
3797        seed_agent_context(&engine, &task_id, 1, &dir.path().to_string_lossy()).await;
3798
3799        engine
3800            .submit_output(
3801                &worker_token,
3802                &task_id,
3803                1,
3804                final_event(serde_json::json!({"plan": "do it"}), true),
3805            )
3806            .await
3807            .expect("submit_output");
3808
3809        let expected_file = dir
3810            .path()
3811            .join("workspace/tasks")
3812            .join(task_id.as_str())
3813            .join("ctx/planner.md");
3814        assert!(
3815            expected_file.exists(),
3816            "materialized submission file missing at {expected_file:?}"
3817        );
3818        let body = std::fs::read_to_string(expected_file).unwrap();
3819        assert!(body.contains(r#""plan": "do it""#), "body: {body}");
3820    }
3821
3822    /// Subtask 4 Test #3: `work_dir` unresolved (no `agent_ctx`
3823    /// snapshot for this `(task_id, attempt)`) — submit still succeeds,
3824    /// fail-open, no file.
3825    #[tokio::test]
3826    async fn submit_output_final_skips_file_when_root_unresolved() {
3827        let (engine, _op, task_id, worker_token) = seeded_task("planner").await;
3828        // No seed_agent_context call — root is unresolved.
3829
3830        let result = engine
3831            .submit_output(
3832                &worker_token,
3833                &task_id,
3834                1,
3835                final_event(serde_json::json!("hi"), true),
3836            )
3837            .await;
3838        assert!(
3839            result.is_ok(),
3840            "submit must succeed even with no resolvable root (fail-open, Invariant 1)"
3841        );
3842    }
3843
3844    /// Regression for the check_policy cascade: the default
3845    /// [`crate::core::config::CheckPolicy::Warn`] preserves the
3846    /// pre-`CheckPolicy` fail-open semantics — a submit whose root is
3847    /// unresolved still succeeds. Byte-compat with
3848    /// `submit_output_final_skips_file_when_root_unresolved`; this test
3849    /// pins the mode explicitly so a future default change to
3850    /// `Strict` (silent breakage) is caught here.
3851    #[tokio::test]
3852    async fn submit_output_final_check_policy_warn_preserves_fail_open() {
3853        let (engine, _op, task_id, worker_token) =
3854            seeded_task_with_policy("planner", crate::core::config::CheckPolicy::Warn).await;
3855
3856        let result = engine
3857            .submit_output(
3858                &worker_token,
3859                &task_id,
3860                1,
3861                final_event(serde_json::json!("hi"), true),
3862            )
3863            .await;
3864        assert!(
3865            result.is_ok(),
3866            "Warn mode preserves fail-open: submit must succeed when root unresolved"
3867        );
3868    }
3869
3870    /// Regression for the check_policy cascade:
3871    /// [`crate::core::config::CheckPolicy::Strict`] surfaces the "no
3872    /// work_dir/project_root resolved" fail-open condition as an
3873    /// [`EngineError::CheckPolicyStrict`], letting a caller who has
3874    /// opted in fail fast instead of proceeding with a partially-
3875    /// realized submission. The error's `context` identifies the call
3876    /// site (`"file materialize"`), and `message` preserves the
3877    /// pre-`CheckPolicy` warn literal verbatim (log-parse compat).
3878    #[tokio::test]
3879    async fn submit_output_final_check_policy_strict_surfaces_error_when_root_unresolved() {
3880        let (engine, _op, task_id, worker_token) =
3881            seeded_task_with_policy("planner", crate::core::config::CheckPolicy::Strict).await;
3882
3883        let err = engine
3884            .submit_output(
3885                &worker_token,
3886                &task_id,
3887                1,
3888                final_event(serde_json::json!("hi"), true),
3889            )
3890            .await
3891            .expect_err("Strict mode must return an error when root unresolved");
3892        match err {
3893            EngineError::CheckPolicyStrict { context, message } => {
3894                assert!(
3895                    context.contains("file materialize"),
3896                    "context must identify the call site: {context}"
3897                );
3898                assert!(
3899                    message.contains("no work_dir/project_root resolved"),
3900                    "message must preserve the warn-log literal for log-parse compat: {message}"
3901                );
3902            }
3903            other => panic!(
3904                "expected EngineError::CheckPolicyStrict, got a different variant: {other:?}"
3905            ),
3906        }
3907    }
3908
3909    /// Regression for the check_policy cascade:
3910    /// [`crate::core::config::CheckPolicy::Silent`] returns `Ok(())` (
3911    /// like `Warn`) without surfacing an error. The log-suppression side
3912    /// of `Silent` (no `tracing::warn!`) is enforced at the call site
3913    /// via the `if !matches!(policy, Silent) { warn!(...) }` guard —
3914    /// verifying tracing output shape here would couple the test to a
3915    /// subscriber setup, so the assertion is limited to the error-
3916    /// return semantics (matches the helper unit tests in
3917    /// `check_policy_helper_tests`).
3918    #[tokio::test]
3919    async fn submit_output_final_check_policy_silent_returns_ok_when_root_unresolved() {
3920        let (engine, _op, task_id, worker_token) =
3921            seeded_task_with_policy("planner", crate::core::config::CheckPolicy::Silent).await;
3922
3923        let result = engine
3924            .submit_output(
3925                &worker_token,
3926                &task_id,
3927                1,
3928                final_event(serde_json::json!("hi"), true),
3929            )
3930            .await;
3931        assert!(
3932            result.is_ok(),
3933            "Silent mode returns Ok(()) at the error surface: submit must succeed"
3934        );
3935    }
3936
3937    /// Subtask 4 Test #4 (file half): re-submitting under the same
3938    /// `(task_id, agent)` overwrites the materialized file with the
3939    /// latest value.
3940    #[tokio::test]
3941    async fn resubmit_overwrites_materialized_file_with_latest() {
3942        let dir = tempfile::TempDir::new().unwrap();
3943        let (engine, _op, task_id, worker_token) = seeded_task("planner").await;
3944        seed_agent_context(&engine, &task_id, 1, &dir.path().to_string_lossy()).await;
3945
3946        engine
3947            .submit_output(
3948                &worker_token,
3949                &task_id,
3950                1,
3951                final_event(serde_json::json!("first"), true),
3952            )
3953            .await
3954            .expect("first submit");
3955        engine
3956            .submit_output(
3957                &worker_token,
3958                &task_id,
3959                1,
3960                final_event(serde_json::json!("second"), true),
3961            )
3962            .await
3963            .expect("second submit");
3964
3965        let expected_file = dir
3966            .path()
3967            .join("workspace/tasks")
3968            .join(task_id.as_str())
3969            .join("ctx/planner.md");
3970        let body = std::fs::read_to_string(expected_file).unwrap();
3971        assert!(body.contains("second"), "body must reflect latest: {body}");
3972        assert!(
3973            !body.contains("first"),
3974            "body must not carry the stale value: {body}"
3975        );
3976    }
3977
3978    /// GH #27 (follow-up to #23): the byte-compat default
3979    /// `ProjectionPlacement` (`root_preference = WorkDir`) falls back to
3980    /// `project_root` when `work_dir` is absent — the same fallback
3981    /// [`crate::core::projection_placement::ProjectionPlacement::resolve_root`]
3982    /// now performs for every one of the "3 path" call sites, this one
3983    /// exercised at the submit-sink layer.
3984    #[tokio::test]
3985    async fn submit_output_final_falls_back_to_project_root_when_work_dir_absent() {
3986        let dir = tempfile::TempDir::new().unwrap();
3987        let (engine, _op, task_id, worker_token) = seeded_task("planner").await;
3988        seed_agent_context_roots(
3989            &engine,
3990            &task_id,
3991            1,
3992            None,
3993            Some(&dir.path().to_string_lossy()),
3994        )
3995        .await;
3996
3997        engine
3998            .submit_output(
3999                &worker_token,
4000                &task_id,
4001                1,
4002                final_event(serde_json::json!({"plan": "via project_root"}), true),
4003            )
4004            .await
4005            .expect("submit_output");
4006
4007        let expected_file = dir
4008            .path()
4009            .join("workspace/tasks")
4010            .join(task_id.as_str())
4011            .join("ctx/planner.md");
4012        assert!(
4013            expected_file.exists(),
4014            "materialized submission file missing at {expected_file:?} \
4015             (work_dir absent must fall back to project_root)"
4016        );
4017    }
4018
4019    /// GH #27 (follow-up to #23): a declared `ProjectionPlacement`
4020    /// (`root_preference = ProjectRoot`, custom `dir_template`) changes
4021    /// BOTH which root is preferred (project_root wins even though
4022    /// work_dir is also present) AND the target directory layout — proof
4023    /// the submit sink consults the snapshotted resolver rather than a
4024    /// hardcoded layout.
4025    #[tokio::test]
4026    async fn submit_output_final_uses_declared_projection_placement() {
4027        let work_dir = tempfile::TempDir::new().unwrap();
4028        let project_root = tempfile::TempDir::new().unwrap();
4029        let (engine, _op, task_id, worker_token) = seeded_task("planner").await;
4030        seed_agent_context_roots(
4031            &engine,
4032            &task_id,
4033            1,
4034            Some(&work_dir.path().to_string_lossy()),
4035            Some(&project_root.path().to_string_lossy()),
4036        )
4037        .await;
4038        seed_projection_placement(
4039            &engine,
4040            &task_id,
4041            crate::core::projection_placement::ProjectionPlacement {
4042                root_preference: crate::core::projection_placement::RootPreference::ProjectRoot,
4043                dir_template: "custom/{task_id}/out".to_string(),
4044            },
4045        )
4046        .await;
4047
4048        engine
4049            .submit_output(
4050                &worker_token,
4051                &task_id,
4052                1,
4053                final_event(serde_json::json!({"plan": "via custom placement"}), true),
4054            )
4055            .await
4056            .expect("submit_output");
4057
4058        let expected_file = project_root
4059            .path()
4060            .join("custom")
4061            .join(task_id.as_str())
4062            .join("out/planner.md");
4063        assert!(
4064            expected_file.exists(),
4065            "materialized submission file missing at custom placement target {expected_file:?}"
4066        );
4067        let unexpected_file = work_dir
4068            .path()
4069            .join("workspace/tasks")
4070            .join(task_id.as_str())
4071            .join("ctx/planner.md");
4072        assert!(
4073            !unexpected_file.exists(),
4074            "declared root_preference=ProjectRoot must not fall back to work_dir: {unexpected_file:?}"
4075        );
4076    }
4077
4078    /// Subtask 4 Invariant 3 / crux requirement #3: when
4079    /// [`Engine::set_output_store`] wires a Data-plane [`crate::store::output::OutputStore`],
4080    /// `submit_output`'s `Final` dual-writes into it under
4081    /// `producer_agent = TaskState.spec.agent` — the store becomes
4082    /// queryable via `get_latest_by_name`, independent of whether a root
4083    /// resolved for the file half.
4084    #[tokio::test]
4085    async fn submit_output_final_dual_writes_into_configured_output_store() {
4086        let (engine, _op, task_id, worker_token) = seeded_task("reviewer").await;
4087        let data_store: Arc<dyn crate::store::output::OutputStore> =
4088            Arc::new(InMemoryOutputStore::new());
4089        engine.set_output_store(data_store.clone());
4090
4091        engine
4092            .submit_output(
4093                &worker_token,
4094                &task_id,
4095                1,
4096                final_event(serde_json::json!({"verdict": "pass"}), true),
4097            )
4098            .await
4099            .expect("submit_output");
4100
4101        let record = data_store
4102            .get_latest_by_name("reviewer")
4103            .await
4104            .expect("dual-written record");
4105        match record.event {
4106            OutputEvent::Final { content, ok } => {
4107                assert!(ok);
4108                match content {
4109                    ContentRef::Inline { value } => {
4110                        assert_eq!(value, serde_json::json!({"verdict": "pass"}));
4111                    }
4112                    other => panic!("expected Inline content, got {other:?}"),
4113                }
4114            }
4115            other => panic!("expected Final event, got {other:?}"),
4116        }
4117    }
4118
4119    /// GH #34 subtask-3 gap fix: an `Artifact` event submitted via
4120    /// `submit_output` dual-writes into a wired Data-plane `OutputStore`
4121    /// under its OWN `name`, verbatim — mirrors
4122    /// `submit_output_final_dual_writes_into_configured_output_store`
4123    /// above, but for the `Artifact` variant.
4124    #[tokio::test]
4125    async fn submit_output_artifact_dual_writes_into_configured_output_store() {
4126        let (engine, _op, task_id, worker_token) = seeded_task("echo").await;
4127        let data_store: Arc<dyn crate::store::output::OutputStore> =
4128            Arc::new(InMemoryOutputStore::new());
4129        engine.set_output_store(data_store.clone());
4130
4131        engine
4132            .submit_output(
4133                &worker_token,
4134                &task_id,
4135                1,
4136                OutputEvent::Artifact {
4137                    name: "audit:echo".to_string(),
4138                    content: ContentRef::Inline {
4139                        value: serde_json::json!({"finding": "clean"}),
4140                    },
4141                },
4142            )
4143            .await
4144            .expect("submit_output");
4145
4146        let record = data_store
4147            .get_latest_by_name("audit:echo")
4148            .await
4149            .expect("dual-written artifact record");
4150        match record.event {
4151            OutputEvent::Artifact { name, content } => {
4152                assert_eq!(name, "audit:echo");
4153                match content {
4154                    ContentRef::Inline { value } => {
4155                        assert_eq!(value, serde_json::json!({"finding": "clean"}));
4156                    }
4157                    other => panic!("expected Inline content, got {other:?}"),
4158                }
4159            }
4160            other => panic!("expected Artifact event, got {other:?}"),
4161        }
4162        // The `Artifact` dual-write must never collide with / overwrite
4163        // the producing step's own `Final` name — `submit_output` never
4164        // materialized a `Final` here, so `"echo"` must stay unresolved.
4165        assert!(
4166            data_store.get_latest_by_name("echo").await.is_err(),
4167            "artifact write must not fabricate a record under the raw producer_agent name"
4168        );
4169    }
4170
4171    /// Invariant 1 (fail-open) for `Artifact`, mirroring
4172    /// `submit_output_final_skips_file_when_root_unresolved`'s Final-side
4173    /// coverage: no `OutputStore` wired at all — submit still succeeds.
4174    #[tokio::test]
4175    async fn submit_output_artifact_is_fail_open_when_no_output_store_configured() {
4176        let (engine, _op, task_id, worker_token) = seeded_task("echo").await;
4177
4178        let result = engine
4179            .submit_output(
4180                &worker_token,
4181                &task_id,
4182                1,
4183                OutputEvent::Artifact {
4184                    name: "audit:echo".to_string(),
4185                    content: ContentRef::Inline {
4186                        value: serde_json::json!("finding"),
4187                    },
4188                },
4189            )
4190            .await;
4191        assert!(
4192            result.is_ok(),
4193            "submit must succeed even with no OutputStore wired (fail-open, Invariant 1)"
4194        );
4195    }
4196
4197    /// `submit_worker_result_trusted` (the `/v1/worker/submit` short-handle
4198    /// path) triggers the exact same sink as `submit_output` — parity
4199    /// across both worker-submit entry points.
4200    #[tokio::test]
4201    async fn submit_worker_result_trusted_also_triggers_projection_sink() {
4202        let dir = tempfile::TempDir::new().unwrap();
4203        let (engine, _op, task_id, _worker_token) = seeded_task("planner").await;
4204        seed_agent_context(&engine, &task_id, 1, &dir.path().to_string_lossy()).await;
4205        let data_store: Arc<dyn crate::store::output::OutputStore> =
4206            Arc::new(InMemoryOutputStore::new());
4207        engine.set_output_store(data_store.clone());
4208
4209        engine
4210            .submit_worker_result_trusted(&task_id, 1, serde_json::json!("trusted-value"), true)
4211            .await
4212            .expect("submit_worker_result_trusted");
4213
4214        let expected_file = dir
4215            .path()
4216            .join("workspace/tasks")
4217            .join(task_id.as_str())
4218            .join("ctx/planner.md");
4219        assert!(expected_file.exists());
4220        let record = data_store
4221            .get_latest_by_name("planner")
4222            .await
4223            .expect("dual-written record");
4224        assert!(matches!(record.event, OutputEvent::Final { ok: true, .. }));
4225    }
4226
4227    /// GH #23 subtask-2 (canonical sink): a declared `projection_name`
4228    /// (`AgentMeta.projection_name`, surfaced via `StepNaming`) redirects
4229    /// `submit_output`'s Final canonical sink — both the Data-plane
4230    /// dual-write name and the materialized file stem resolve to the
4231    /// canonical name, not the raw `producer_agent`.
4232    #[tokio::test]
4233    async fn submit_output_final_uses_canonical_name_when_step_naming_declares_one() {
4234        let dir = tempfile::TempDir::new().unwrap();
4235        let (engine, _op, task_id, worker_token) = seeded_task("reviewer").await;
4236        seed_agent_context(&engine, &task_id, 1, &dir.path().to_string_lossy()).await;
4237        seed_step_naming(&engine, &task_id, "reviewer", "verdict-final").await;
4238        let data_store: Arc<dyn crate::store::output::OutputStore> =
4239            Arc::new(InMemoryOutputStore::new());
4240        engine.set_output_store(data_store.clone());
4241
4242        engine
4243            .submit_output(
4244                &worker_token,
4245                &task_id,
4246                1,
4247                final_event(serde_json::json!({"verdict": "pass"}), true),
4248            )
4249            .await
4250            .expect("submit_output");
4251
4252        let record = data_store
4253            .get_latest_by_name("verdict-final")
4254            .await
4255            .expect("dual-written record under canonical name");
4256        assert!(matches!(record.event, OutputEvent::Final { ok: true, .. }));
4257        assert!(
4258            data_store.get_latest_by_name("reviewer").await.is_err(),
4259            "raw producer_agent name must not be written once canonical resolves"
4260        );
4261
4262        let expected_file = dir
4263            .path()
4264            .join("workspace/tasks")
4265            .join(task_id.as_str())
4266            .join("ctx/verdict-final.md");
4267        assert!(
4268            expected_file.exists(),
4269            "materialized file stem must be canonical at {expected_file:?}"
4270        );
4271    }
4272
4273    /// GH #23 subtask-2: no `StepNaming` table snapshotted for this
4274    /// `task_id` (the pre-GH-#23 / no-`with_step_naming` path) is a
4275    /// defensive fail-open — the canonical sink falls back to the raw
4276    /// `producer_agent`, byte-identical to
4277    /// `submit_output_final_dual_writes_into_configured_output_store`
4278    /// above (which never calls `seed_step_naming`).
4279    #[tokio::test]
4280    async fn submit_output_final_falls_back_to_producer_agent_when_no_step_naming_table() {
4281        let (engine, _op, task_id, worker_token) = seeded_task("reviewer").await;
4282        let data_store: Arc<dyn crate::store::output::OutputStore> =
4283            Arc::new(InMemoryOutputStore::new());
4284        engine.set_output_store(data_store.clone());
4285
4286        engine
4287            .submit_output(
4288                &worker_token,
4289                &task_id,
4290                1,
4291                final_event(serde_json::json!({"verdict": "pass"}), true),
4292            )
4293            .await
4294            .expect("submit_output");
4295
4296        let record = data_store
4297            .get_latest_by_name("reviewer")
4298            .await
4299            .expect("fail-open dual-write under raw producer_agent name");
4300        assert!(matches!(record.event, OutputEvent::Final { ok: true, .. }));
4301    }
4302
4303    /// GH #23 subtask-2 (Layer 2): `OutputStore::get_latest_by_name_in_run`
4304    /// resolves the value `submit_output` dual-wrote for this exact
4305    /// `(task_id, attempt)` run, independent of `get_latest_by_name`'s
4306    /// cross-Run race (two Runs sharing a producer name never bleed into
4307    /// each other through the Run-scoped accessor).
4308    #[tokio::test]
4309    async fn submit_output_final_is_resolvable_via_run_scoped_lookup() {
4310        let (engine, _op, task_id, worker_token) = seeded_task("reviewer").await;
4311        let data_store: Arc<dyn crate::store::output::OutputStore> =
4312            Arc::new(InMemoryOutputStore::new());
4313        engine.set_output_store(data_store.clone());
4314
4315        engine
4316            .submit_output(
4317                &worker_token,
4318                &task_id,
4319                1,
4320                final_event(serde_json::json!({"verdict": "pass"}), true),
4321            )
4322            .await
4323            .expect("submit_output");
4324
4325        let record = data_store
4326            .get_latest_by_name_in_run(task_id.as_str(), 1, "reviewer")
4327            .await
4328            .expect("run-scoped lookup resolves the dual-written record");
4329        assert!(matches!(record.event, OutputEvent::Final { ok: true, .. }));
4330
4331        // A different attempt of the same task must not resolve — the
4332        // Run-scoped lookup does not fall back across attempts.
4333        assert!(
4334            data_store
4335                .get_latest_by_name_in_run(task_id.as_str(), 2, "reviewer")
4336                .await
4337                .is_err(),
4338            "a different attempt must not resolve the same-named record"
4339        );
4340    }
4341
4342    // ─── staged part file materialize ───
4343
4344    /// Staging a part with a resolved `work_dir` writes
4345    /// `<work_dir>/workspace/tasks/<task_id>/ctx/<name>` with the part's
4346    /// content RAW (no front matter / fenced wrapper).
4347    #[tokio::test]
4348    async fn stage_artifact_materializes_part_file_when_work_dir_resolved() {
4349        let dir = tempfile::TempDir::new().unwrap();
4350        let (engine, _op, task_id, _worker_token) = seeded_task("planner").await;
4351        seed_agent_context(&engine, &task_id, 1, &dir.path().to_string_lossy()).await;
4352
4353        engine
4354            .stage_worker_artifact_trusted(
4355                &task_id,
4356                1,
4357                "plan.md".to_string(),
4358                serde_json::json!("# Plan\n\nstep one\n"),
4359            )
4360            .await
4361            .expect("stage artifact");
4362
4363        let expected_file = dir
4364            .path()
4365            .join("workspace/tasks")
4366            .join(task_id.as_str())
4367            .join("ctx/plan.md");
4368        assert!(
4369            expected_file.exists(),
4370            "materialized part file missing at {expected_file:?}"
4371        );
4372        let body = std::fs::read_to_string(expected_file).unwrap();
4373        // Raw — no YAML front matter / fenced-json wrapper.
4374        assert_eq!(body, "# Plan\n\nstep one\n");
4375    }
4376
4377    /// No resolvable root + `Warn` — staging still
4378    /// succeeds (fail-open), and no part file is written.
4379    #[tokio::test]
4380    async fn stage_artifact_check_policy_warn_skips_part_file_when_root_unresolved() {
4381        let dir = tempfile::TempDir::new().unwrap();
4382        let (engine, _op, task_id, _worker_token) =
4383            seeded_task_with_policy("planner", crate::core::config::CheckPolicy::Warn).await;
4384        // No seed_agent_context — root unresolved.
4385
4386        let result = engine
4387            .stage_worker_artifact_trusted(
4388                &task_id,
4389                1,
4390                "plan.md".to_string(),
4391                serde_json::json!("x"),
4392            )
4393            .await;
4394        assert!(
4395            result.is_ok(),
4396            "Warn mode preserves fail-open: stage must succeed when root unresolved"
4397        );
4398        assert!(
4399            !dir.path().join("workspace").exists(),
4400            "no part file may be materialized when root is unresolved"
4401        );
4402    }
4403
4404    /// No resolvable root + `Strict` — staging surfaces
4405    /// the fail-open condition as an [`EngineError::CheckPolicyStrict`],
4406    /// its message identifying the "part file materialize" call site.
4407    #[tokio::test]
4408    async fn stage_artifact_check_policy_strict_surfaces_error_when_root_unresolved() {
4409        let (engine, _op, task_id, _worker_token) =
4410            seeded_task_with_policy("planner", crate::core::config::CheckPolicy::Strict).await;
4411
4412        let err = engine
4413            .stage_worker_artifact_trusted(
4414                &task_id,
4415                1,
4416                "plan.md".to_string(),
4417                serde_json::json!("x"),
4418            )
4419            .await
4420            .expect_err("Strict mode must return an error when root unresolved");
4421        match err {
4422            EngineError::CheckPolicyStrict { context, message } => {
4423                assert!(
4424                    context.contains("part file materialize"),
4425                    "context must identify the call site: {context}"
4426                );
4427                assert!(
4428                    message.contains("part file materialize"),
4429                    "message must identify the part-file sink: {message}"
4430                );
4431                assert!(
4432                    message.contains("no work_dir/project_root resolved"),
4433                    "message must preserve the warn-log literal: {message}"
4434                );
4435            }
4436            other => panic!(
4437                "expected EngineError::CheckPolicyStrict, got a different variant: {other:?}"
4438            ),
4439        }
4440    }
4441
4442    /// A path-traversal `name` (`../evil.md`) with a
4443    /// resolved root — the name guard fails the write, but fail-open keeps
4444    /// the stage succeeding, and nothing is written outside the ctx dir.
4445    #[tokio::test]
4446    async fn stage_artifact_traversal_name_is_fail_open_and_writes_nothing() {
4447        let dir = tempfile::TempDir::new().unwrap();
4448        let (engine, _op, task_id, _worker_token) = seeded_task("planner").await;
4449        seed_agent_context(&engine, &task_id, 1, &dir.path().to_string_lossy()).await;
4450
4451        let result = engine
4452            .stage_worker_artifact_trusted(
4453                &task_id,
4454                1,
4455                "../evil.md".to_string(),
4456                serde_json::json!("pwned"),
4457            )
4458            .await;
4459        assert!(
4460            result.is_ok(),
4461            "default (Warn) policy is fail-open even on a rejected part name"
4462        );
4463        // The escaped target (ctx dir's parent) must not have been written.
4464        let escaped = dir
4465            .path()
4466            .join("workspace/tasks")
4467            .join(task_id.as_str())
4468            .join("evil.md");
4469        assert!(
4470            !escaped.exists(),
4471            "a traversal name must never write outside the ctx dir: {escaped:?}"
4472        );
4473    }
4474}
4475
4476/// GH #36 ST1: named multi-part worker output. Covers (a) the pure
4477/// `fold_final_and_parts` assembly `dispatch_attempt_with`'s Final-pull
4478/// delegates to, (b) `stage_worker_artifact_trusted`'s per-attempt
4479/// isolation on `EngineState.output_store` / `.worker_artifact_names` (the
4480/// same `HashMap<(StepId, u32), _>` key shape `submit_worker_result_trusted`
4481/// uses — a fresh attempt is a fresh key, so nothing to explicitly "clean
4482/// up"), and (c) the allowlist behavior that keeps a non-opt-in `Artifact`
4483/// producer (e.g. `AfterRunAuditMiddleware`) from being folded in.
4484#[cfg(test)]
4485mod named_multi_part_worker_output_tests {
4486    use super::*;
4487    use crate::worker::output::{ContentRef, OutputEvent};
4488
4489    fn artifact(name: &str, value: Value) -> OutputEvent {
4490        OutputEvent::Artifact {
4491            name: name.to_string(),
4492            content: ContentRef::Inline { value },
4493        }
4494    }
4495
4496    fn final_ev(value: Value, ok: bool) -> OutputEvent {
4497        OutputEvent::Final {
4498            content: ContentRef::Inline { value },
4499            ok,
4500        }
4501    }
4502
4503    fn names(list: &[&str]) -> Vec<String> {
4504        list.iter().map(|s| s.to_string()).collect()
4505    }
4506
4507    /// Two staged parts (both in `staged_names`) + a `Final` fold into
4508    /// `{"out", "parts"}`, each value carried through verbatim.
4509    #[test]
4510    fn fold_final_and_parts_assembles_out_and_parts_shape() {
4511        let tail = vec![
4512            artifact("summary", serde_json::json!("the summary")),
4513            artifact("diff", serde_json::json!({"lines": 3})),
4514            final_ev(serde_json::json!("final text"), true),
4515        ];
4516        let staged = names(&["summary", "diff"]);
4517        let (value, ok) = fold_final_and_parts(&tail, &staged).expect("Final present");
4518        assert!(ok);
4519        assert_eq!(
4520            value,
4521            serde_json::json!({
4522                "out": "final text",
4523                "parts": {
4524                    "summary": "the summary",
4525                    "diff": {"lines": 3},
4526                }
4527            })
4528        );
4529    }
4530
4531    /// Zero staged parts: the value is exactly the plain `Final` value — no
4532    /// `{"out", "parts"}` wrapping. This is the back-compat guarantee (GH
4533    /// #36 must not change the shape for a worker that never POSTs to
4534    /// `/v1/worker/artifact`).
4535    #[test]
4536    fn fold_final_and_parts_with_no_parts_returns_plain_final_value() {
4537        let tail = vec![final_ev(serde_json::json!("plain value"), true)];
4538        let (value, ok) = fold_final_and_parts(&tail, &[]).expect("Final present");
4539        assert!(ok);
4540        assert_eq!(value, serde_json::json!("plain value"));
4541    }
4542
4543    /// The same staged part `name` appearing twice in one attempt: the
4544    /// LATER (tail-order) value wins — `parts` is a `Map`, not an
4545    /// accumulating list.
4546    #[test]
4547    fn fold_final_and_parts_same_name_twice_last_write_wins() {
4548        let tail = vec![
4549            artifact("a", serde_json::json!("first")),
4550            artifact("a", serde_json::json!("second")),
4551            final_ev(serde_json::json!("f"), true),
4552        ];
4553        let staged = names(&["a"]);
4554        let (value, _ok) = fold_final_and_parts(&tail, &staged).expect("Final present");
4555        assert_eq!(
4556            value,
4557            serde_json::json!({"out": "f", "parts": {"a": "second"}})
4558        );
4559    }
4560
4561    /// No `Final` anywhere in the tail (only staged parts, e.g. the worker
4562    /// crashed before submitting) — `None`, the caller's pre-existing "no
4563    /// Final in output_tail" error path.
4564    #[test]
4565    fn fold_final_and_parts_returns_none_when_no_final_present() {
4566        let tail = vec![artifact("a", serde_json::json!("v"))];
4567        let staged = names(&["a"]);
4568        assert!(fold_final_and_parts(&tail, &staged).is_none());
4569    }
4570
4571    /// An `Artifact` on the tail whose name is NOT in `staged_names` (e.g.
4572    /// `AfterRunAuditMiddleware`'s `"audit:<step_ref>"` sidecar finding on
4573    /// an audited step's own tail) must NOT be folded into `"parts"` — the
4574    /// value stays the plain `Final` value, exactly the pre-GH-#36
4575    /// behavior for every producer that isn't the worker's own
4576    /// `/v1/worker/artifact` staging. This is the regression this fold was
4577    /// almost shipped without (see `dispatch_attempt_with`'s doc).
4578    #[test]
4579    fn fold_final_and_parts_ignores_artifacts_outside_the_staged_allowlist() {
4580        let tail = vec![
4581            final_ev(serde_json::json!({"echoed": "hi"}), true),
4582            artifact("audit:echo", serde_json::json!({"finding": "clean"})),
4583        ];
4584        // `staged_names` empty: the worker itself never staged anything —
4585        // the audit sidecar Artifact must be ignored.
4586        let (value, ok) = fold_final_and_parts(&tail, &[]).expect("Final present");
4587        assert!(ok);
4588        assert_eq!(value, serde_json::json!({"echoed": "hi"}));
4589    }
4590
4591    /// Mixed tail: one staged (allowlisted) part and one non-staged
4592    /// (audit-style) `Artifact` — only the staged one is folded in.
4593    #[test]
4594    fn fold_final_and_parts_folds_only_the_staged_subset_of_a_mixed_tail() {
4595        let tail = vec![
4596            artifact("summary", serde_json::json!("s")),
4597            artifact("audit:echo", serde_json::json!({"finding": "clean"})),
4598            final_ev(serde_json::json!("f"), true),
4599        ];
4600        let staged = names(&["summary"]);
4601        let (value, _ok) = fold_final_and_parts(&tail, &staged).expect("Final present");
4602        assert_eq!(
4603            value,
4604            serde_json::json!({"out": "f", "parts": {"summary": "s"}})
4605        );
4606    }
4607
4608    /// `stage_worker_artifact_trusted` writes onto the `(task_id, attempt)`
4609    /// key exactly like `submit_worker_result_trusted` does — a part staged
4610    /// under attempt N is invisible to an `output_tail` / allowlist read of
4611    /// attempt N+1 (a fresh attempt starts empty; nothing carries over).
4612    #[tokio::test]
4613    async fn stage_worker_artifact_trusted_is_isolated_per_attempt() {
4614        let engine = Engine::new(EngineCfg::default());
4615        let task_id = StepId::new();
4616
4617        engine
4618            .stage_worker_artifact_trusted(&task_id, 1, "a".to_string(), serde_json::json!("v1"))
4619            .await
4620            .expect("stage attempt 1");
4621
4622        let attempt_1_tail = engine.output_tail(&task_id, 1).await;
4623        assert_eq!(attempt_1_tail.len(), 1);
4624        assert!(matches!(
4625            &attempt_1_tail[0],
4626            OutputEvent::Artifact { name, .. } if name == "a"
4627        ));
4628        assert_eq!(
4629            engine.worker_artifact_names_for(&task_id, 1).await,
4630            vec!["a".to_string()]
4631        );
4632
4633        let attempt_2_tail = engine.output_tail(&task_id, 2).await;
4634        assert!(
4635            attempt_2_tail.is_empty(),
4636            "attempt 2 must not see attempt 1's staged part"
4637        );
4638        assert!(
4639            engine
4640                .worker_artifact_names_for(&task_id, 2)
4641                .await
4642                .is_empty(),
4643            "attempt 2's allowlist must not see attempt 1's staged name"
4644        );
4645    }
4646}
4647
4648// ─── GH #50 (Subtask 2): `Engine::register_verdict_contracts` /
4649// `Engine::verdict_contract_for_task` ────────────────────────────────────
4650#[cfg(test)]
4651mod verdict_contract_registry_tests {
4652    use super::*;
4653
4654    async fn seeded_engine(agent: &str) -> (Engine, StepId) {
4655        let engine = Engine::new(EngineCfg::default());
4656        let op_token = engine
4657            .attach("ut-op", Role::Operator, Duration::from_secs(30))
4658            .await
4659            .expect("attach");
4660        let task_id = engine
4661            .start_task(
4662                &op_token,
4663                TaskSpec {
4664                    agent: agent.to_string(),
4665                    initial_directive: serde_json::json!("x"),
4666                    step_ctx: None,
4667                    check_policy: None,
4668                },
4669            )
4670            .await
4671            .expect("start_task");
4672        (engine, task_id)
4673    }
4674
4675    /// An agent with no registered contract at all → `None` (the opt-in
4676    /// default; every pre-GH-#50 `Engine`).
4677    #[tokio::test]
4678    async fn returns_none_when_no_contract_registered_for_the_agent() {
4679        let (engine, task_id) = seeded_engine("gate").await;
4680        assert_eq!(engine.verdict_contract_for_task(&task_id).await, None);
4681    }
4682
4683    /// A registered contract for the running task's agent is returned
4684    /// verbatim.
4685    #[tokio::test]
4686    async fn returns_the_registered_contract_for_the_running_agent() {
4687        let (engine, task_id) = seeded_engine("gate").await;
4688        let contract = mlua_swarm_schema::VerdictContract {
4689            channel: mlua_swarm_schema::VerdictChannel::Body,
4690            values: vec!["PASS".to_string(), "BLOCKED".to_string()],
4691        };
4692        engine.register_verdict_contracts(HashMap::from([("gate".to_string(), contract.clone())]));
4693        assert_eq!(
4694            engine.verdict_contract_for_task(&task_id).await,
4695            Some(contract)
4696        );
4697    }
4698
4699    /// A registered contract for a DIFFERENT agent name never leaks onto
4700    /// an unrelated task.
4701    #[tokio::test]
4702    async fn does_not_leak_a_contract_registered_for_a_different_agent() {
4703        let (engine, task_id) = seeded_engine("gate").await;
4704        engine.register_verdict_contracts(HashMap::from([(
4705            "other-agent".to_string(),
4706            mlua_swarm_schema::VerdictContract {
4707                channel: mlua_swarm_schema::VerdictChannel::Body,
4708                values: vec!["PASS".to_string()],
4709            },
4710        )]));
4711        assert_eq!(engine.verdict_contract_for_task(&task_id).await, None);
4712    }
4713
4714    /// An unknown `task_id` → `None`, not a panic / error.
4715    #[tokio::test]
4716    async fn returns_none_for_an_unknown_task_id() {
4717        let engine = Engine::new(EngineCfg::default());
4718        let unknown = StepId::new();
4719        assert_eq!(engine.verdict_contract_for_task(&unknown).await, None);
4720    }
4721
4722    /// `register_verdict_contracts` is additive (`HashMap::extend`): a
4723    /// second call registering a DIFFERENT agent does not clobber the
4724    /// first call's entry.
4725    #[tokio::test]
4726    async fn register_verdict_contracts_is_additive_across_calls() {
4727        let (engine, task_id) = seeded_engine("gate").await;
4728        let contract = mlua_swarm_schema::VerdictContract {
4729            channel: mlua_swarm_schema::VerdictChannel::Part,
4730            values: vec!["ALLOW".to_string()],
4731        };
4732        engine.register_verdict_contracts(HashMap::from([("gate".to_string(), contract.clone())]));
4733        engine.register_verdict_contracts(HashMap::from([(
4734            "unrelated-agent".to_string(),
4735            mlua_swarm_schema::VerdictContract {
4736                channel: mlua_swarm_schema::VerdictChannel::Body,
4737                values: vec!["X".to_string()],
4738            },
4739        )]));
4740        assert_eq!(
4741            engine.verdict_contract_for_task(&task_id).await,
4742            Some(contract)
4743        );
4744    }
4745}
4746
4747// ─── GH #51: completion-time verdict-contract enforcement — the shared
4748// `Engine::verdict_contract_completion_check` choke point embedded inside
4749// `submit_worker_result_trusted` / `submit_output`, exercised here at the
4750// `submit_output` level (the WS Operator fallback route's own unit-test
4751// coverage — see `crates/mlua-swarm-server/tests/verdict_contract.rs` for
4752// the HTTP-round-trip coverage of the other 2 routes) ───────────────────
4753#[cfg(test)]
4754mod verdict_contract_completion_tests {
4755    use super::*;
4756
4757    /// Seeds a `Pending` task bound to `agent` and mints a bound
4758    /// `Role::Worker` token for it — the same mint-and-register pattern
4759    /// `initial_directive_value_passthrough_tests::mint_worker_token`
4760    /// uses (duplicated here: that helper is private to its own sibling
4761    /// `#[cfg(test)]` module, not reachable via `super::*` from this one).
4762    async fn seeded_task_with_worker_token(agent: &str) -> (Engine, CapToken, StepId) {
4763        let engine = Engine::new(EngineCfg::default());
4764        let op_token = engine
4765            .attach("ut-op", Role::Operator, Duration::from_secs(30))
4766            .await
4767            .expect("attach");
4768        let task_id = engine
4769            .start_task(
4770                &op_token,
4771                TaskSpec {
4772                    agent: agent.to_string(),
4773                    initial_directive: serde_json::json!("x"),
4774                    step_ctx: None,
4775                    check_policy: None,
4776                },
4777            )
4778            .await
4779            .expect("start_task");
4780        let worker_token = engine.signer().session(
4781            format!("worker-of-{task_id}"),
4782            Role::Worker,
4783            vec!["*".into()],
4784            Duration::from_secs(600),
4785        );
4786        let fp = worker_token.fingerprint();
4787        let record = CapTokenRecord::from_worker_token(worker_token.clone(), task_id.clone());
4788        engine
4789            .with_state("test.mint_worker", move |s| {
4790                s.tokens.insert(fp, record);
4791            })
4792            .await
4793            .expect("mint worker token");
4794        (engine, worker_token, task_id)
4795    }
4796
4797    fn body_contract(values: &[&str]) -> mlua_swarm_schema::VerdictContract {
4798        mlua_swarm_schema::VerdictContract {
4799            channel: mlua_swarm_schema::VerdictChannel::Body,
4800            values: values.iter().map(|v| v.to_string()).collect(),
4801        }
4802    }
4803
4804    fn part_contract(values: &[&str]) -> mlua_swarm_schema::VerdictContract {
4805        mlua_swarm_schema::VerdictContract {
4806            channel: mlua_swarm_schema::VerdictChannel::Part,
4807            values: values.iter().map(|v| v.to_string()).collect(),
4808        }
4809    }
4810
4811    fn final_event(value: Value, ok: bool) -> crate::worker::output::OutputEvent {
4812        crate::worker::output::OutputEvent::Final {
4813            content: crate::worker::output::ContentRef::Inline { value },
4814            ok,
4815        }
4816    }
4817
4818    /// Route 3 (WS Operator fallback, `submit_output` level) — a
4819    /// `channel: "part"` contract's attempt completes via a plain
4820    /// `Final` without ever staging a `"verdict"` artifact: rejected
4821    /// with `EngineError::VerdictPartMissing`, and nothing lands on
4822    /// `output_tail` — the rejected value never reaches the flow ctx.
4823    #[tokio::test]
4824    async fn submit_output_rejects_missing_verdict_part() {
4825        let (engine, token, task_id) = seeded_task_with_worker_token("gate").await;
4826        engine.register_verdict_contracts(HashMap::from([(
4827            "gate".to_string(),
4828            part_contract(&["PASS", "BLOCKED"]),
4829        )]));
4830
4831        let err = engine
4832            .submit_output(
4833                &token,
4834                &task_id,
4835                1,
4836                final_event(serde_json::json!("anything"), true),
4837            )
4838            .await
4839            .expect_err("missing staged verdict part must be rejected");
4840        assert!(
4841            matches!(err, EngineError::VerdictPartMissing { .. }),
4842            "unexpected error variant: {err:?}"
4843        );
4844
4845        let tail = engine.output_tail(&task_id, 1).await;
4846        assert!(
4847            !tail
4848                .iter()
4849                .any(|ev| matches!(ev, crate::worker::output::OutputEvent::Final { .. })),
4850            "a rejected completion must not write a Final onto output_tail"
4851        );
4852    }
4853
4854    /// Route 3 — a `channel: "part"` contract completes normally when the
4855    /// worker DID stage a matching `"verdict"` artifact first (defense in
4856    /// depth: presence AND membership both hold).
4857    #[tokio::test]
4858    async fn submit_output_accepts_when_verdict_part_is_staged_and_a_member() {
4859        let (engine, token, task_id) = seeded_task_with_worker_token("gate").await;
4860        engine.register_verdict_contracts(HashMap::from([(
4861            "gate".to_string(),
4862            part_contract(&["PASS", "BLOCKED"]),
4863        )]));
4864        engine
4865            .stage_worker_artifact_trusted(
4866                &task_id,
4867                1,
4868                "verdict".to_string(),
4869                serde_json::json!("PASS"),
4870            )
4871            .await
4872            .expect("stage verdict part");
4873
4874        engine
4875            .submit_output(
4876                &token,
4877                &task_id,
4878                1,
4879                final_event(serde_json::json!("full report"), true),
4880            )
4881            .await
4882            .expect("staged + member verdict part must be accepted");
4883
4884        let tail = engine.output_tail(&task_id, 1).await;
4885        assert!(
4886            tail.iter()
4887                .any(|ev| matches!(ev, crate::worker::output::OutputEvent::Final { .. })),
4888            "an accepted completion must write its Final onto output_tail"
4889        );
4890    }
4891
4892    /// Route 3 — a `channel: "body"` contract's completing value is NOT a
4893    /// member of `values`: rejected with
4894    /// `EngineError::VerdictValueRejected`, no `Final` written.
4895    #[tokio::test]
4896    async fn submit_output_rejects_body_value_outside_contract() {
4897        let (engine, token, task_id) = seeded_task_with_worker_token("gate").await;
4898        engine.register_verdict_contracts(HashMap::from([(
4899            "gate".to_string(),
4900            body_contract(&["PASS", "BLOCKED"]),
4901        )]));
4902
4903        let err = engine
4904            .submit_output(
4905                &token,
4906                &task_id,
4907                1,
4908                final_event(serde_json::json!("UNKNOWN"), true),
4909            )
4910            .await
4911            .expect_err("out-of-contract body value must be rejected");
4912        match err {
4913            EngineError::VerdictValueRejected { value, allowed } => {
4914                assert_eq!(value, "UNKNOWN");
4915                assert_eq!(allowed, vec!["PASS".to_string(), "BLOCKED".to_string()]);
4916            }
4917            other => panic!("unexpected error variant: {other:?}"),
4918        }
4919
4920        let tail = engine.output_tail(&task_id, 1).await;
4921        assert!(
4922            !tail
4923                .iter()
4924                .any(|ev| matches!(ev, crate::worker::output::OutputEvent::Final { .. })),
4925            "a rejected completion must not write a Final onto output_tail"
4926        );
4927    }
4928
4929    /// `ok=false` bypasses the completion-time check entirely, regardless
4930    /// of channel or membership — the exemption acceptance criterion,
4931    /// exercised at the `submit_output` choke point.
4932    #[tokio::test]
4933    async fn submit_output_ok_false_bypasses_the_check() {
4934        let (engine, token, task_id) = seeded_task_with_worker_token("gate").await;
4935        engine.register_verdict_contracts(HashMap::from([(
4936            "gate".to_string(),
4937            body_contract(&["PASS", "BLOCKED"]),
4938        )]));
4939
4940        engine
4941            .submit_output(
4942                &token,
4943                &task_id,
4944                1,
4945                final_event(serde_json::json!("UNKNOWN"), false),
4946            )
4947            .await
4948            .expect("ok=false must bypass the verdict contract check entirely");
4949
4950        let tail = engine.output_tail(&task_id, 1).await;
4951        assert!(
4952            tail.iter()
4953                .any(|ev| matches!(ev, crate::worker::output::OutputEvent::Final { .. })),
4954            "an ok=false completion is exempt, not rejected — its Final must still land"
4955        );
4956    }
4957
4958    /// `staged_verdict_value_for` mirrors `fold_final_and_parts`'s
4959    /// last-write-wins semantics: staging `"verdict"` twice within the
4960    /// same attempt returns the LAST value, not the first.
4961    #[tokio::test]
4962    async fn staged_verdict_value_for_is_last_write_wins() {
4963        let (engine, _token, task_id) = seeded_task_with_worker_token("gate").await;
4964        engine
4965            .stage_worker_artifact_trusted(
4966                &task_id,
4967                1,
4968                "verdict".to_string(),
4969                serde_json::json!("PASS"),
4970            )
4971            .await
4972            .expect("stage first verdict part");
4973        engine
4974            .stage_worker_artifact_trusted(
4975                &task_id,
4976                1,
4977                "verdict".to_string(),
4978                serde_json::json!("BLOCKED"),
4979            )
4980            .await
4981            .expect("stage second verdict part");
4982
4983        assert_eq!(
4984            engine.staged_verdict_value_for(&task_id, 1).await,
4985            Some("BLOCKED".to_string())
4986        );
4987    }
4988
4989    /// `staged_verdict_value_for` ignores artifacts staged under any name
4990    /// OTHER than the literal `"verdict"` — mirrors `channel: "part"`
4991    /// contracts only ever addressing that one part.
4992    #[tokio::test]
4993    async fn staged_verdict_value_for_ignores_other_artifact_names() {
4994        let (engine, _token, task_id) = seeded_task_with_worker_token("gate").await;
4995        engine
4996            .stage_worker_artifact_trusted(
4997                &task_id,
4998                1,
4999                "notes".to_string(),
5000                serde_json::json!("irrelevant"),
5001            )
5002            .await
5003            .expect("stage unrelated part");
5004
5005        assert_eq!(engine.staged_verdict_value_for(&task_id, 1).await, None);
5006    }
5007
5008    /// `staged_verdict_value_for` → `None` when nothing was ever staged —
5009    /// the normal case the completion check turns into
5010    /// `EngineError::VerdictPartMissing`.
5011    #[tokio::test]
5012    async fn staged_verdict_value_for_returns_none_when_nothing_staged() {
5013        let (engine, _token, task_id) = seeded_task_with_worker_token("gate").await;
5014        assert_eq!(engine.staged_verdict_value_for(&task_id, 1).await, None);
5015    }
5016}