Skip to main content

mlua_swarm/blueprint/
compiler.rs

1//! Blueprint `Compiler`, `CompiledAgentTable`, and the three default
2//! `SpawnerFactory` implementations.
3//!
4//! ## Pipeline
5//!
6//! ```text
7//! Blueprint (= flow + agents + hints + strategy + spawner_hints)
8//!     │
9//!     │ Compiler.compile(&bp)          ← this module (AgentDef → SpawnerAdapter table)
10//!     ▼
11//! CompiledBlueprint {
12//!     router: Arc<CompiledAgentTable>, // ctx.agent → SpawnerAdapter lookup
13//!     flow:   FlowNode,                // the flow.ir source (evaluated via EngineDispatcher)
14//!     metadata: BlueprintMetadata,
15//! }
16//!     │
17//!     │ service::linker::link(router, blueprint.spawner_hints.layers, &engine)
18//!     ▼                                   ↑ Layer wrapping is done separately (src/service/linker.rs)
19//! `Arc<dyn SpawnerAdapter>`            (already wrapped with base + hint SpawnerLayers)
20//!     │
21//!     ▼ EngineDispatcher::with_spawner → engine.dispatch_attempt_with
22//! ```
23//!
24//! `CompiledAgentTable` is a thin table: it looks up `routes[name]` by
25//! `ctx.agent` and hands the spawn off to the matching `SpawnerAdapter`.
26//! The `routes` map is built at compile time through `SpawnerFactory`
27//! implementations. Layer wrapping is not part of this module — it lives
28//! in `service::linker::link`.
29
30use crate::blueprint::{
31    resolve_bound_agents, AgentDef, AgentKind, AgentProfile, Blueprint, BlueprintMetadata,
32    BoundAgent, BoundAgentResolveError, Runner,
33};
34use crate::core::ctx::Ctx;
35use crate::core::engine::Engine;
36use crate::core::projection_placement::{ProjectionPlacement, ProjectionPlacementError};
37use crate::core::step_naming::{StepNaming, StepNamingError};
38use crate::operator::{Operator, OperatorSlotResolver, OperatorSpawner, WorkerBinding};
39use crate::types::{CapToken, StepId};
40use crate::worker::adapter::{InProcSpawner, SpawnError, SpawnerAdapter, WorkerFn};
41use crate::worker::process_spawner::{ProcessSpawner, StreamMode};
42use crate::worker::Worker;
43use async_trait::async_trait;
44use mlua_flow_ir::{Expr, Node as FlowNode, Path};
45use mlua_swarm_schema::{VerdictChannel, VerdictContract};
46use serde_json::Value;
47use std::collections::{BTreeMap, HashMap};
48use std::sync::Arc;
49use thiserror::Error;
50
51// ─── error ───────────────────────────────────────────────────────────────
52
53/// Everything that can go wrong while `Compiler::compile` turns a
54/// `Blueprint` into a `CompiledBlueprint`.
55#[derive(Debug, Error)]
56pub enum CompileError {
57    /// Runner / Agent / Context binding failed before any spawner was built.
58    #[error("bound agent resolution: {0}")]
59    BoundAgent(#[from] BoundAgentResolveError),
60    /// An `AgentDef.kind` has no matching entry in the `SpawnerRegistry`
61    /// and `Blueprint.strategy.strict_kind` is set.
62    #[error("unknown agent kind in SpawnerRegistry: {0:?}")]
63    UnknownKind(AgentKind),
64    /// The `AgentDef.spec` shape did not match what the factory for its
65    /// kind requires (missing/mistyped field, etc.).
66    #[error("agent '{name}' spec invalid: {msg}")]
67    InvalidSpec {
68        /// The offending agent's name.
69        name: String,
70        /// Human-readable description of what was wrong with the spec.
71        msg: String,
72    },
73    /// The flow references an agent name that has no corresponding
74    /// `AgentDef` (and no default spawner is configured).
75    #[error("flow references agent '{0}' but no AgentDef matches")]
76    UnresolvedRef(String),
77    /// Two `AgentDef`s in the same `Blueprint` share a name.
78    #[error("duplicate AgentDef name: {0}")]
79    DuplicateAgent(String),
80    /// A `kind = Operator` agent's `spec.operator_ref` does not match
81    /// any `OperatorDef.name` declared in `Blueprint.operators`.
82    #[error("agent '{agent}' operator_ref '{op_ref}' does not match any OperatorDef.name in Blueprint.operators (defined: {defined:?})")]
83    UnresolvedOperatorRef {
84        /// The agent whose `operator_ref` didn't resolve.
85        agent: String,
86        /// The `operator_ref` value that was looked up.
87        op_ref: String,
88        /// The `OperatorDef.name`s that *are* declared, for the error
89        /// message.
90        defined: Vec<String>,
91    },
92    /// `Blueprint.spawner_hints.layers` names a layer that has been
93    /// removed from the engine — currently only `"operator_delegate"`.
94    ///
95    /// # Why this is an error and not a skipped key
96    ///
97    /// `service::linker::link` skips hint keys the deployment's
98    /// `LayerRegistry` does not answer, on purpose: it keeps a Blueprint
99    /// portable across deployments that install different layers. That
100    /// leniency is exactly wrong for a key the engine used to answer and
101    /// no longer does — the Blueprint would keep parsing, keep
102    /// registering, and quietly run a different execution shape than the
103    /// author wrote, with nothing anywhere saying a declared capability
104    /// had been dropped. Being lenient about an unknown capability and
105    /// being lenient about a *withdrawn* one are different bets, and only
106    /// the first one pays.
107    ///
108    /// Refusing at compile is also what makes the removal legible in the
109    /// one place that can name the replacement. See the
110    /// `removed-spawner-hint` lint and
111    /// `mse://guides/blueprint-authoring`.
112    #[error(
113        "spawner_hints.layers declares '{key}' but that layer has been removed: {reason}. \
114         Drop the key and route through the AgentSpec axis instead — declare the seat in \
115         `operators[]`, point the agent at it with `spec.operator_ref`, and pin its holder \
116         per launch with `operator_sid`"
117    )]
118    RemovedSpawnerHint {
119        /// The removed hint key the Blueprint still declares.
120        key: String,
121        /// Why the layer went, in one clause — folded into the message so
122        /// an author reading only the error text learns the cause, not
123        /// just the verdict.
124        reason: String,
125    },
126    /// GH #21 Phase 2: an `AgentMeta.meta_ref` or a statically-visible
127    /// `$step_meta.ref` (inside a `Step.in` **Lit** expr) does not match
128    /// any `MetaDef.name` declared in `Blueprint.metas`.
129    #[error("{where_} names an undefined MetaDef: '{meta_ref}' (defined: {defined:?})")]
130    UnresolvedMetaRef {
131        /// Human-readable description of where the reference was found
132        /// (e.g. `"AgentMeta.meta_ref of agent 'planner'"` or `"Step
133        /// 'scout' $step_meta.ref"`).
134        where_: String,
135        /// The `meta_ref` value that was looked up.
136        meta_ref: String,
137        /// The `MetaDef.name`s that *are* declared, for the error
138        /// message.
139        defined: Vec<String>,
140    },
141    /// GH #23: two Steps' canonical/alias projection names collide and at
142    /// least one side declared `AgentMeta.projection_name` — see
143    /// [`crate::core::step_naming::StepNaming::from_blueprint`]'s doc for
144    /// the full resolution rule (an undeclared/undeclared clash is a soft
145    /// warning instead, logged but not rejected).
146    #[error("StepNaming collision: {0}")]
147    StepNamingCollision(#[from] StepNamingError),
148    /// GH #27 (follow-up to #23): `Blueprint.projection_placement` failed
149    /// validation — see
150    /// [`crate::core::projection_placement::ProjectionPlacement::from_spec`]'s
151    /// doc for the rejection rules (`dir_template` empty / missing the
152    /// `{task_id}` placeholder / absolute / containing a `..` segment, or
153    /// `root` not `"work_dir"`/`"project_root"`).
154    #[error("invalid projection_placement: {0}")]
155    InvalidProjectionPlacement(#[from] ProjectionPlacementError),
156    /// GH #34: an `audits[].agent` name does not match any `AgentDef.name`
157    /// declared in `Blueprint.agents` — mirrors the `operator_ref`
158    /// validation above (same "design-time reference must resolve"
159    /// discipline).
160    #[error("audits[].agent '{agent}' does not match any AgentDef.name in Blueprint.agents (defined: {defined:?})")]
161    UnresolvedAuditAgent {
162        /// The `audits[].agent` value that was looked up.
163        agent: String,
164        /// The `AgentDef.name`s that *are* declared, for the error
165        /// message.
166        defined: Vec<String>,
167    },
168    /// GH #50: a `Branch`/`Loop` `cond` compares a contract-bearing
169    /// agent's output using the wrong OUTPUT channel — e.g. the agent
170    /// declares `channel: "part"` (verdict staged as the named part
171    /// `"verdict"`, addressed `$.<step>.parts.verdict`) but the cond
172    /// addresses the bare step output (`$.<step>`) instead, or vice
173    /// versa. See the `blueprint-authoring.md` guide's "Returning
174    /// verdicts to drive BP flow" section for Pattern A (`channel:
175    /// "body"`) vs Pattern B (`channel: "part"`).
176    #[error(
177        "agent '{agent}' declares verdict channel '{expected_channel}' but {where_} \
178         addresses it as '{actual_shape}' output — see the \"Returning verdicts to drive \
179         BP flow\" guide's Pattern A (channel: \"body\") / Pattern B (channel: \"part\")"
180    )]
181    VerdictChannelMismatch {
182        /// Human-readable description of where the offending cond was
183        /// found (e.g. `"Branch cond"` / `"Loop cond"`).
184        where_: String,
185        /// The agent whose declared `verdict.channel` didn't match.
186        agent: String,
187        /// The agent's declared channel (`"body"` or `"part"`).
188        expected_channel: String,
189        /// The channel shape the cond's `Path` actually addressed
190        /// (`"body"` or `"part"`).
191        actual_shape: String,
192    },
193    /// GH #50: a `Branch`/`Loop` `cond`'s `Lit` operand (or, for `In`, one
194    /// of the `Lit` haystack's array elements) is not a member of a
195    /// contract-bearing agent's declared `verdict.values` closed token
196    /// set.
197    #[error(
198        "agent '{agent}' verdict Lit '{value}' at {where_} is not a member of the declared \
199         values {values:?}"
200    )]
201    VerdictValueNotInContract {
202        /// Human-readable description of where the offending cond was
203        /// found (e.g. `"Branch cond"` / `"Loop cond"`).
204        where_: String,
205        /// The agent whose declared `verdict.values` didn't contain
206        /// `value`.
207        agent: String,
208        /// The offending `Lit` value, rendered as a string (the raw JSON
209        /// representation when it is not itself a JSON string — a
210        /// non-string `Lit` can never be a member of `values: Vec<String>`
211        /// either way).
212        value: String,
213        /// The agent's declared `verdict.values` closed token set, for the
214        /// error message.
215        values: Vec<String>,
216    },
217    /// GH #50 follow-up (issue `33bc825b`): a contract-bearing agent
218    /// declares `verdict.values = [...]` but at least one member of that
219    /// closed token set is never referenced by any downstream
220    /// `Branch`/`Loop` `cond` `Lit` — the flow author declared a verdict
221    /// value they never wrote a handler for. Emitted only when the
222    /// Blueprint opts in via
223    /// [`BlueprintMetadata::strict_verdict_handling`]`= Some(true)`; under
224    /// the default (`None`/`Some(false)`) unhandled values surface as
225    /// `tracing::warn!` only and compilation succeeds (back-compat with
226    /// Blueprints that intentionally leave some verdict values as
227    /// silent-pass informational tokens).
228    #[error(
229        "agent '{agent}' declares verdict value '{value}' but no downstream Branch/Loop \
230         cond references it (declared: {declared_values:?}, at step '{step_ref}') — either \
231         handle the value downstream or drop it from `verdict.values`"
232    )]
233    VerdictValueUnhandled {
234        /// The agent whose declared `verdict.values` entry lacks a
235        /// downstream handler.
236        agent: String,
237        /// The declared value that has no downstream `cond` reference.
238        value: String,
239        /// The agent's full declared `verdict.values` closed token set,
240        /// for the error message.
241        declared_values: Vec<String>,
242        /// The `Step.ref_` where this agent is invoked. When the same
243        /// agent is invoked at multiple sites, the first one encountered
244        /// during flow walk is reported (best-effort — the diagnostic
245        /// still identifies the offending agent uniquely).
246        step_ref: String,
247    },
248}
249
250/// Stable prefix of the `InvalidSpec` message the operator factory emits
251/// when a WS-thin-path operator agent lacks its worker binding. Shared
252/// by the message construction site
253/// ([`OperatorSpawnerFactory::build`]) and the
254/// [`From<&CompileError>`] specialization below, so the two can never
255/// drift apart (GH #79 — the CLI used to re-detect this case by
256/// substring-matching the *formatted* error, which broke silently on
257/// any wording change).
258pub const WORKER_BINDING_REQUIRED_MSG_PREFIX: &str =
259    "profile.worker_binding is required for this operator backend";
260
261/// The `spawner_hints.layers` keys the engine used to answer and no
262/// longer does, each with the one-clause reason it went. `None` for every
263/// other key — including keys this deployment simply does not install,
264/// which stay leniently skipped by `service::linker::link`.
265///
266/// Shared deliberately by both stages that report the
267/// `removed-spawner-hint` lint: the compile gate below (which refuses)
268/// and `bp_doctor`'s `spawner_hint_lint` family (which reports on an
269/// already-registered Blueprint, since registering does not compile).
270/// Keeping the table in one function is what stops the two stages from
271/// disagreeing about which keys are dead — the failure mode the earlier
272/// substring-matching of formatted error text kept producing.
273pub fn removed_spawner_hint_reason(key: &str) -> Option<&'static str> {
274    match key {
275        "operator_delegate" => Some(
276            "the Blueprint-global Operator delegate axis was removed because it could not \
277             follow a seat handover (it resolved its destination from the launch-time \
278             `operator_backend_id`, never from `Run.current`) and could not carry an \
279             agent's `system_prompt` (it had no per-agent spawner, so it passed `system: \
280             None` and never baked one for `/v1/worker/prompt`)",
281        ),
282        _ => None,
283    }
284}
285
286/// GH #79 Phase 2: project every [`CompileError`] variant into the
287/// unified [`Diagnostic`] shape (`mlua-swarm-diag`), preserving the
288/// variant's typed fields into `span` / `notes` / `help` directly — no
289/// substring re-parse of the `#[error(...)]` strings.
290///
291/// Every diagnostic is `stage: CompileLint` / `level: Error` (a
292/// `CompileError` always aborts the compile). The `kind` keys match
293/// [`mlua_swarm_diag::LINT_DECLS`] entries one-to-one — asserted by
294/// this module's `every_compile_error_variant_maps_to_a_declared_lint`
295/// test.
296///
297/// One specialization: an [`CompileError::InvalidSpec`] whose message
298/// carries [`WORKER_BINDING_REQUIRED_MSG_PREFIX`] maps to the
299/// dual-stage kind `worker-binding-missing` (the same lint `bp_doctor`
300/// reports as `Warn` post-register) instead of the generic
301/// `invalid-agent-spec` — one lint kind, one docs anchor, one
302/// downstream switch key across both stages.
303impl From<&CompileError> for mlua_swarm_diag::Diagnostic {
304    fn from(err: &CompileError) -> Self {
305        use mlua_swarm_diag::{
306            Applicability, DiagElement, DiagLevel, DiagSpan, DiagStage, Diagnostic, DocsRef,
307            Suggestion,
308        };
309        let base = |kind: &'static str| {
310            Diagnostic::new(
311                kind,
312                DiagStage::CompileLint,
313                DiagLevel::Error,
314                err.to_string(),
315            )
316        };
317        let agent_span = |name: &str| DiagSpan {
318            element: DiagElement::Agent {
319                name: name.to_string(),
320            },
321            json_path: Some(format!("$.agents[?(@.name=='{name}')]")),
322        };
323        match err {
324            CompileError::BoundAgent(_) => base("bound-agent-resolution"),
325            CompileError::UnknownKind(_) => base("unknown-agent-kind").with_help(
326                "register a SpawnerFactory for this kind, or disable strategy.strict_kind",
327            ),
328            CompileError::InvalidSpec { name, msg }
329                if msg.starts_with(WORKER_BINDING_REQUIRED_MSG_PREFIX) =>
330            {
331                Diagnostic::new(
332                    "worker-binding-missing",
333                    DiagStage::CompileLint,
334                    DiagLevel::Error,
335                    format!(
336                        "operator agent '{name}' has no explicit Runner or legacy \
337                         `profile.worker_binding`"
338                    ),
339                )
340                .with_note(msg.clone())
341                .with_suggestion(Suggestion {
342                    msg: "add an explicit Runner (or legacy profile.worker_binding)".into(),
343                    patch: "runner = { backend = \"ws_operator\", variant = \"claude\", \
344                            tools = {} }"
345                        .into(),
346                    applicability: Applicability::HasPlaceholders,
347                })
348                .with_docs_ref(DocsRef {
349                    uri: "mse://guides/bp-dsl-templates",
350                    anchor: None,
351                })
352                .with_span(agent_span(name))
353            }
354            CompileError::InvalidSpec { name, .. } => {
355                base("invalid-agent-spec").with_span(agent_span(name))
356            }
357            CompileError::UnresolvedRef(ref_) => base("unresolved-agent-ref").with_span(DiagSpan {
358                element: DiagElement::Step { ref_: ref_.clone() },
359                json_path: None,
360            }),
361            CompileError::DuplicateAgent(name) => {
362                base("duplicate-agent-name").with_span(agent_span(name))
363            }
364            CompileError::UnresolvedOperatorRef { agent, defined, .. } => {
365                base("unresolved-operator-ref")
366                    .with_note(format!("declared OperatorDef names: {defined:?}"))
367                    .with_span(agent_span(agent))
368            }
369            CompileError::RemovedSpawnerHint { key, .. } => base("removed-spawner-hint")
370                .with_note(
371                    "`service::linker::link` skips hint keys a deployment does not install, \
372                     so leaving this key in place would drop the capability silently rather \
373                     than report it"
374                        .to_string(),
375                )
376                .with_help(
377                    "route the spawn through the AgentSpec axis: the seat is declared once in \
378                     `operators[]`, each agent that should reach an Operator selects it with \
379                     `spec.operator_ref`, and the launch names who holds the seat for that run \
380                     via `operator_sid` (which a later handover can move without recompiling)",
381                )
382                // Shared with `bp_doctor`'s arm for the same kind rather
383                // than restated here — see
384                // `removed_spawner_hint_suggestion`'s doc for why the two
385                // stages cannot be allowed to drift, and for the
386                // `HasPlaceholders` grading.
387                .with_suggestion(mlua_swarm_diag::removed_spawner_hint_suggestion())
388                .with_docs_ref(DocsRef {
389                    uri: "mse://guides/blueprint-authoring",
390                    anchor: Some("removed-spawner-hint-layers"),
391                })
392                .with_span(DiagSpan {
393                    element: DiagElement::BlueprintRoot,
394                    json_path: Some(format!("$.spawner_hints.layers[?(@=='{key}')]")),
395                }),
396            CompileError::UnresolvedMetaRef { defined, .. } => base("unresolved-meta-ref")
397                .with_note(format!("declared MetaDef names: {defined:?}")),
398            CompileError::StepNamingCollision(_) => base("step-naming-collision"),
399            CompileError::InvalidProjectionPlacement(_) => base("invalid-projection-placement")
400                .with_span(DiagSpan {
401                    element: DiagElement::BlueprintRoot,
402                    json_path: Some("$.projection_placement".into()),
403                }),
404            CompileError::UnresolvedAuditAgent { defined, .. } => base("unresolved-audit-agent")
405                .with_note(format!("declared AgentDef names: {defined:?}"))
406                .with_span(DiagSpan {
407                    element: DiagElement::BlueprintRoot,
408                    json_path: Some("$.audits".into()),
409                }),
410            CompileError::VerdictChannelMismatch { agent, .. } => base("verdict-channel-mismatch")
411                .with_help(
412                    "see the \"Returning verdicts to drive BP flow\" guide's Pattern A \
413                         (channel: \"body\") / Pattern B (channel: \"part\")",
414                )
415                .with_docs_ref(DocsRef {
416                    uri: "mse://guides/blueprint-authoring",
417                    anchor: None,
418                })
419                .with_span(agent_span(agent)),
420            CompileError::VerdictValueNotInContract { agent, .. } => {
421                base("verdict-value-not-in-contract")
422                    // The patch is deliberately the same prose recipe the
423                    // legacy FixHint carried (GH #62) — CLI stderr and the
424                    // bp_build response render it verbatim, and the
425                    // `bp_build_cli` smoke test asserts on the
426                    // `agents[N].verdict.values` pointer inside it.
427                    .with_suggestion(Suggestion {
428                        msg: "align the cond literal with the agent's declared verdict \
429                              contract"
430                            .into(),
431                        patch: "either add the cond's literal to `agents[N].verdict.values`, \
432                                or change the cond to a value that is already declared"
433                            .into(),
434                        applicability: Applicability::MaybeIncorrect,
435                    })
436                    .with_docs_ref(DocsRef {
437                        uri: "mse://guides/blueprint-authoring",
438                        anchor: None,
439                    })
440                    .with_span(agent_span(agent))
441            }
442            CompileError::VerdictValueUnhandled {
443                agent,
444                declared_values,
445                ..
446            } => base("verdict-value-unhandled")
447                .with_note(format!("declared verdict.values: {declared_values:?}"))
448                .with_help(
449                    "either handle the value in a downstream Branch/Loop cond, or drop it \
450                     from verdict.values",
451                )
452                .with_span(agent_span(agent)),
453        }
454    }
455}
456
457// ─── SpawnerFactory + Registry ───────────────────────────────────────────
458
459/// Factory trait that interprets an `AgentDef` and builds the concrete
460/// `SpawnerAdapter`. Register one per kind. Parsing the spec,
461/// validating it, and baking the profile are the implementation's job.
462///
463/// The signature was widened in v9 from `(name, spec, hint)` to
464/// `(&AgentDef, hint)` so the profile can be passed through. Most
465/// implementations still just pull `&agent_def.name` and
466/// `&agent_def.spec`, but Operator-backend factories consume
467/// `agent_def.profile` to bake the persona in.
468pub trait SpawnerFactory: Send + Sync {
469    /// Build the concrete `SpawnerAdapter` for one `AgentDef`. `hint` is
470    /// the matching entry (if any) from `Blueprint.hints.per_agent`.
471    fn build(
472        &self,
473        agent_def: &AgentDef,
474        hint: Option<&Value>,
475    ) -> Result<Arc<dyn SpawnerAdapter>, CompileError>;
476}
477
478/// Companion trait that carries the **type-side source of truth** for
479/// the Adapter ↔ `AgentKind` correspondence.
480///
481/// The base [`SpawnerFactory`] trait deliberately does not carry an
482/// associated const so it stays dyn-compatible — that is, so it can be
483/// stored and dispatched as `Arc<dyn SpawnerFactory>`. This companion
484/// trait splits `const KIND: AgentKind` out, and
485/// [`SpawnerRegistry::register`] uses `F::KIND` as the `HashMap` key.
486/// That physically removes the string-lookup failure mode at the type
487/// layer.
488///
489/// The three built-in factories (`Shell` / `InProc` / `Operator`)
490/// implement this. Extension backends (say, `AgentBlockSpawnerFactory`)
491/// follow the same explicit two-step recipe: add a new `AgentKind`
492/// variant and implement this trait.
493pub trait SpawnerFactoryKind: SpawnerFactory {
494    /// The `AgentKind` this factory handles — used as the `HashMap` key
495    /// by `SpawnerRegistry::register`.
496    const KIND: AgentKind;
497    /// The concrete Worker type produced by this `AgentKind` — this
498    /// binds the type chain all the way from `AgentKind` down to `Worker`.
499    /// Every factory declares it so the `AgentKind → Worker` mapping is
500    /// explicit across all four layers. It is the source of truth for
501    /// preserving the concrete type right up until `SpawnerAdapter::spawn`
502    /// erases it into `Box<dyn Worker>`.
503    type Worker: crate::worker::Worker;
504}
505
506/// `AgentKind → SpawnerFactory` mapping. The compiler looks entries up
507/// during `compile()`.
508#[derive(Clone)]
509pub struct SpawnerRegistry {
510    factories: HashMap<AgentKind, Arc<dyn SpawnerFactory>>,
511}
512
513impl SpawnerRegistry {
514    /// Start with an empty `AgentKind → SpawnerFactory` mapping.
515    pub fn new() -> Self {
516        Self {
517            factories: HashMap::new(),
518        }
519    }
520    /// **Type-driven registration** — takes `F::KIND` and uses it as the
521    /// `HashMap` key.
522    ///
523    /// Callers use the form
524    /// `reg.register::<SubprocessProcessSpawnerFactory>(Arc::new(...))`
525    /// and never have to pass an `AgentKind` literal. The Adapter ↔ Kind
526    /// correspondence is enforced at the type layer, physically removing
527    /// the string / enum-literal lookup failure mode.
528    pub fn register<F: SpawnerFactoryKind + 'static>(&mut self, factory: Arc<F>) -> &mut Self {
529        let f: Arc<dyn SpawnerFactory> = factory;
530        self.factories.insert(F::KIND, f);
531        self
532    }
533}
534
535impl Default for SpawnerRegistry {
536    fn default() -> Self {
537        Self::new()
538    }
539}
540
541// ─── Compiler ────────────────────────────────────────────────────────────
542
543/// Turns a `Blueprint` into a `CompiledBlueprint` by resolving every
544/// `AgentDef` against a `SpawnerRegistry`. One-shot: build a fresh
545/// `Compiler` per `compile()` call (or reuse it — it holds no
546/// per-compile state).
547pub struct Compiler {
548    registry: SpawnerRegistry,
549    default_spawner: Option<Arc<dyn SpawnerAdapter>>,
550}
551
552/// The result of `Compiler::compile` — a routing table plus the
553/// unmodified flow and metadata, ready to hand to
554/// `EngineDispatcher::with_spawner` / `mlua_flow_ir::eval_async`.
555pub struct CompiledBlueprint {
556    /// `ctx.agent → SpawnerAdapter` lookup table.
557    pub router: Arc<CompiledAgentTable>,
558    /// The flow.ir source, copied verbatim from `Blueprint.flow`.
559    pub flow: FlowNode,
560    /// Copied verbatim from `Blueprint.metadata`.
561    pub metadata: BlueprintMetadata,
562    /// GH #23: the Blueprint's [`StepNaming`] addressing-space table,
563    /// built once here (the sole construction site — see
564    /// [`StepNaming::from_blueprint`]'s doc) and threaded through
565    /// `EngineDispatcher::with_step_naming` for `EngineState` storage.
566    pub step_naming: Arc<StepNaming>,
567    /// GH #27 (follow-up to #23): the Blueprint's [`ProjectionPlacement`]
568    /// resolver, built once here (the sole construction site — see
569    /// [`ProjectionPlacement::from_spec`]'s doc) and threaded through
570    /// `EngineDispatcher::with_projection_placement` for `EngineState`
571    /// storage.
572    pub projection_placement: Arc<ProjectionPlacement>,
573}
574
575fn project_bound_agent_for_legacy_factories(bound: &BoundAgent) -> AgentDef {
576    let mut agent = bound.agent.clone();
577    match &bound.runner {
578        Some(Runner::WsOperator { variant, tools })
579        | Some(Runner::WsClaudeCode { variant, tools }) => {
580            let profile = agent.profile.get_or_insert_with(AgentProfile::default);
581            profile.worker_binding = Some(variant.clone());
582            profile.tools = tools.clone();
583        }
584        Some(Runner::AgentBlockInProcess { tools }) => {
585            let profile = agent.profile.get_or_insert_with(AgentProfile::default);
586            profile.worker_binding = None;
587            profile.tools = tools.clone();
588        }
589        // GH #83: the Subprocess EmbedAgent backend has no legacy profile
590        // projection — the resolved SubprocessDef template reaches
591        // `SubprocessProcessSpawnerFactory` through the build hint, and
592        // profile.model/tools are consumed by the factory directly.
593        Some(Runner::Subprocess { .. }) => {}
594        None => {}
595    }
596    let meta = agent.meta.get_or_insert_with(Default::default);
597    meta.context_policy = bound.context_policy.clone();
598    agent
599}
600
601/// Rebuild a Blueprint's Agent/Context layers from an immutable binding
602/// snapshot while leaving its flow and non-binding metadata untouched.
603pub(crate) fn materialize_bound_blueprint(
604    bp: &Blueprint,
605    bound_agents: &[BoundAgent],
606) -> Blueprint {
607    let mut effective = bp.clone();
608    effective.agents = bound_agents
609        .iter()
610        .map(project_bound_agent_for_legacy_factories)
611        .collect();
612    // Each effective policy is now pinned on its AgentDef; retaining a
613    // mutable BP-global default would reintroduce registry drift on resume.
614    effective.default_context_policy = None;
615    effective
616}
617
618impl Compiler {
619    /// Build a `Compiler` around the given `SpawnerRegistry`, with no
620    /// default spawner (unresolved flow refs are an error unless
621    /// `with_default` is chained on).
622    pub fn new(registry: SpawnerRegistry) -> Self {
623        Self {
624            registry,
625            default_spawner: None,
626        }
627    }
628
629    /// Set a default spawner — used for flow refs (and unregistered
630    /// `AgentKind`s under non-strict strategy) that don't resolve
631    /// against any `AgentDef`/`SpawnerRegistry` entry.
632    pub fn with_default(mut self, sp: Arc<dyn SpawnerAdapter>) -> Self {
633        self.default_spawner = Some(sp);
634        self
635    }
636
637    /// Resolve every `Blueprint.agents` entry through the registry,
638    /// validate `operator_ref`s and flow refs per `Blueprint.strategy`,
639    /// and return the routing table alongside the untouched flow and
640    /// metadata.
641    pub fn compile(&self, bp: &Blueprint) -> Result<CompiledBlueprint, CompileError> {
642        let bound_agents = resolve_bound_agents(bp)?;
643        self.compile_bound(bp, &bound_agents)
644    }
645
646    /// Compile with an already-resolved immutable binding snapshot. Resume
647    /// paths use this entry point so a mutable Blueprint registry cannot
648    /// silently change the Runner, prompt, contract, or static context policy
649    /// between the original Run and its continuation.
650    ///
651    /// # No launch-scoped session pin here
652    ///
653    /// A pinned launch (`operator_sid`) used to compile every
654    /// `kind = Operator` agent against that session, which baked the
655    /// destination for the Run's whole life. The pin is now the Run's first
656    /// `Assign` instead (`RunStore::acquire_assignee`), and the compile
657    /// bakes only the seat — see [`OperatorSpawnerFactory`]'s doc.
658    pub fn compile_bound(
659        &self,
660        bp: &Blueprint,
661        bound_agents: &[BoundAgent],
662    ) -> Result<CompiledBlueprint, CompileError> {
663        let effective = materialize_bound_blueprint(bp, bound_agents);
664        self.compile_resolved(&effective)
665    }
666
667    fn compile_resolved(&self, bp: &Blueprint) -> Result<CompiledBlueprint, CompileError> {
668        let mut routes: HashMap<String, Arc<dyn SpawnerAdapter>> = HashMap::new();
669        let mut seen: HashMap<String, ()> = HashMap::new();
670        // GH #50: `AgentDef.name` → declared `VerdictContract`, collected
671        // alongside `routes` below (every `verdict: Some(...)` agent, kind
672        // resolution notwithstanding). Consumed by the cond↔output-shape
673        // lint right after the loop, and carried into
674        // `CompiledAgentTable.verdict_contracts`.
675        let mut verdict_contracts: HashMap<String, VerdictContract> = HashMap::new();
676
677        // Withdrawn-capability check, ahead of every validation performed
678        // *here*: a Blueprint that asks for a layer the engine no longer
679        // has is not a Blueprint whose remaining errors are worth
680        // reporting, and the author wants the migration line first.
681        //
682        // "Here" is the qualifier that matters, and it is not the whole of
683        // a compile. Both public entry points do work before reaching this
684        // function — [`Compiler::compile`] resolves the binding snapshot
685        // (`resolve_bound_agents`) and [`Compiler::compile_bound`]
686        // materializes it (`materialize_bound_blueprint`) — so a Blueprint
687        // that both declares a withdrawn layer *and* fails binding
688        // resolution reports the binding error, and its author never sees
689        // the migration line. Hoisting the check into the entry points
690        // would fix that ordering, at the cost of stating it in two places
691        // where today it is stated in one; the case is rare enough that it
692        // has not been judged worth the duplication.
693        for key in &bp.spawner_hints.layers {
694            if let Some(reason) = removed_spawner_hint_reason(key) {
695                return Err(CompileError::RemovedSpawnerHint {
696                    key: key.clone(),
697                    reason: reason.to_string(),
698                });
699            }
700        }
701
702        // Design-time validation (OperatorDef as a first-class value):
703        // every `kind = Operator` agent's `spec.operator_ref` must point at
704        // one of `bp.operators[].name`. A Blueprint with any Operator agent
705        // must therefore declare its operators up front; the empty-operators
706        // backward-compat bypass is retired.
707        let defined: Vec<String> = bp.operators.iter().map(|o| o.name.clone()).collect();
708        for ad in &bp.agents {
709            if !matches!(ad.kind, AgentKind::Operator) {
710                continue;
711            }
712            let op_ref = ad.spec.get("operator_ref").and_then(|v| v.as_str());
713            if let Some(op_ref) = op_ref {
714                if !defined.iter().any(|n| n == op_ref) {
715                    return Err(CompileError::UnresolvedOperatorRef {
716                        agent: ad.name.clone(),
717                        op_ref: op_ref.to_string(),
718                        defined: defined.clone(),
719                    });
720                }
721            }
722            // A missing `op_ref` is reported through OperatorSpawnerFactory.build under a different error.
723        }
724
725        // GH #21 Phase 2: named `MetaDef` pool (`Blueprint.metas`) —
726        // validate every reference against it, mirroring the
727        // `operator_ref` validation above.
728        let metas_defined: Vec<String> = bp.metas.iter().map(|m| m.name.clone()).collect();
729        for ad in &bp.agents {
730            let meta_ref = ad.meta.as_ref().and_then(|m| m.meta_ref.as_ref());
731            if let Some(meta_ref) = meta_ref {
732                if !metas_defined.iter().any(|n| n == meta_ref) {
733                    return Err(CompileError::UnresolvedMetaRef {
734                        where_: format!("AgentMeta.meta_ref of agent '{}'", ad.name),
735                        meta_ref: meta_ref.clone(),
736                        defined: metas_defined.clone(),
737                    });
738                }
739            }
740        }
741        // Best-effort static walk of the flow for `$step_meta.ref`
742        // envelopes embedded in a Step's **Lit** `in` expr — this is a
743        // design-time hint only: a non-`Lit` `Step.in` (e.g. `Path`) is
744        // invisible here and skipped silently; `EngineDispatcher::dispatch`
745        // is the authoritative, loud validation line for those.
746        let mut static_step_meta_refs: Vec<(String, String)> = Vec::new();
747        collect_step_meta_refs(&bp.flow, &mut static_step_meta_refs);
748        for (where_, meta_ref) in static_step_meta_refs {
749            if !metas_defined.iter().any(|n| n == &meta_ref) {
750                return Err(CompileError::UnresolvedMetaRef {
751                    where_,
752                    meta_ref,
753                    defined: metas_defined.clone(),
754                });
755            }
756        }
757
758        // GH #34: `audits[].agent` must name an entry in `Blueprint.agents`
759        // — mirrors the `operator_ref` validation above (design-time
760        // reference must resolve at compile time, before any spawner is
761        // built).
762        let agents_defined: Vec<String> = bp.agents.iter().map(|a| a.name.clone()).collect();
763        for audit in &bp.audits {
764            if !agents_defined.iter().any(|n| n == &audit.agent) {
765                return Err(CompileError::UnresolvedAuditAgent {
766                    agent: audit.agent.clone(),
767                    defined: agents_defined.clone(),
768                });
769            }
770        }
771
772        for ad in &bp.agents {
773            if seen.contains_key(&ad.name) {
774                return Err(CompileError::DuplicateAgent(ad.name.clone()));
775            }
776            seen.insert(ad.name.clone(), ());
777
778            // GH #50: contract registration is orthogonal to spawner
779            // resolution (an agent may declare `verdict` regardless of
780            // whether its `kind` resolves), so it happens unconditionally
781            // here, before the kind-resolution branch below that may
782            // `continue`.
783            if let Some(contract) = &ad.verdict {
784                verdict_contracts.insert(ad.name.clone(), contract.clone());
785            }
786
787            let factory = match self.registry.factories.get(&ad.kind) {
788                Some(f) => f.clone(),
789                None => {
790                    if bp.strategy.strict_kind {
791                        return Err(CompileError::UnknownKind(ad.kind.clone()));
792                    } else {
793                        tracing::warn!(
794                            agent = %ad.name,
795                            kind = ?ad.kind,
796                            "no spawner factory registered for agent kind; \
797                             dropping agent from routing table (strict_kind=false)"
798                        );
799                        continue;
800                    }
801                }
802            };
803            let hint = bp.hints.per_agent.get(&ad.name);
804            // GH #83: a Subprocess agent resolving to `Runner::Subprocess`
805            // gets a compile-synthesized hint carrying its resolved
806            // `SubprocessDef` template + overrides (EmbedAgent mode). Any
807            // other resolution keeps the historical spec-based hint — an
808            // existing Subprocess BP (program/args in spec) is untouched.
809            //
810            // No sibling arm exists for `AgentKind::AgentBlock`: its Runner
811            // input (`tools`) already arrives as `profile.tools` off the
812            // pinned `BoundAgent` snapshot — see the note on
813            // `project_bound_agent_for_legacy_factories` / the
814            // `SUBPROCESS_*_HINT_KEY` consts.
815            let subprocess_hint = if ad.kind == AgentKind::Subprocess {
816                resolve_subprocess_template_hint(bp, ad)?
817            } else {
818                None
819            };
820            let spawner = factory.build(ad, subprocess_hint.as_ref().or(hint))?;
821            routes.insert(ad.name.clone(), spawner);
822        }
823
824        // GH #50: `Branch`/`Loop` cond↔output-shape lint. A contract-
825        // bearing agent's output must be compared the way its declared
826        // `verdict.channel` requires and its `Lit` value(s) must be
827        // members of its declared `verdict.values`; an agent referenced by
828        // a cond but declaring no contract only gets a `tracing::warn!`
829        // (opt-in, back-compat — see `AgentDef::verdict`'s doc). Read-only
830        // inspection of `bp.flow` — no rewriting, no new `Expr` forms.
831        //
832        // GH #50 follow-up (issue `33bc825b`): the reverse-direction lint
833        // — declared `verdict.values` entries that no downstream cond
834        // references — runs in the same walk. Its compile-stage
835        // disposition is resolved per agent from
836        // `BlueprintMetadata.strict_verdict_handling` unioned with the
837        // nearest `lints` layer that declares the kind — `agents[].lints`
838        // first, then `metadata.lints` (see
839        // [`resolve_unhandled_verdict_gates`]); the default still only
840        // surfaces `tracing::warn!` so existing Blueprints that
841        // intentionally leave some verdict values as silent-pass
842        // informational tokens keep compiling unchanged.
843        let unhandled_gates = resolve_unhandled_verdict_gates(bp);
844        verify_verdict_conds(&bp.flow, &verdict_contracts, &unhandled_gates)?;
845
846        if bp.strategy.strict_refs {
847            verify_refs(&bp.flow, &routes, self.default_spawner.is_some())?;
848        }
849
850        // GH #23: build the StepNaming addressing-space table once, here
851        // (the sole construction site). A hard collision (either side
852        // declares `AgentMeta.projection_name`) rejects the compile via
853        // `?` (`StepNamingError` → `CompileError::StepNamingCollision`,
854        // same family as the other Blueprint validation checks above); a
855        // soft undeclared/undeclared collision is logged and compilation
856        // proceeds (pre-GH-#23 union-rule behavior preserved).
857        //
858        // Only STRONG claims (a `Step.ref`, a declared `projection_name`,
859        // or an `out` that is exactly `$.T`) reach either path. Steps
860        // sharing a nesting root (`$.r.a` / `$.r.b`) claim it weakly, and
861        // a contested weak claim is dropped inside `from_blueprint` at
862        // `debug!` level — so the ordinary "several lanes under one root"
863        // Blueprint no longer warns on every compile. See
864        // `StepNaming`'s struct doc for the full ladder + boundary table.
865        let (step_naming, step_naming_warnings) = StepNaming::from_blueprint(bp)?;
866        for warning in &step_naming_warnings {
867            tracing::warn!(
868                name = %warning.name,
869                first_step_ref = %warning.first_step_ref,
870                second_step_ref = %warning.second_step_ref,
871                "StepNaming: undeclared steps' canonical/alias names collide; \
872                 the step whose own ref matches the name keeps it (data-plane priority)"
873            );
874        }
875
876        // GH #27 (follow-up to #23): build the ProjectionPlacement resolver
877        // once, here (the sole construction site) — an invalid
878        // `dir_template` / `root` literal rejects the compile via `?`
879        // (`ProjectionPlacementError` → `CompileError::InvalidProjectionPlacement`,
880        // same family as the other Blueprint validation checks above). No
881        // declared `projection_placement` (the pre-#27 default) resolves
882        // to `ProjectionPlacement::default()` unchanged.
883        let projection_placement =
884            ProjectionPlacement::from_spec(bp.projection_placement.as_ref())?;
885
886        let router = Arc::new(CompiledAgentTable {
887            routes,
888            default: self.default_spawner.clone(),
889            verdict_contracts,
890        });
891        Ok(CompiledBlueprint {
892            router,
893            flow: bp.flow.clone(),
894            metadata: bp.metadata.clone(),
895            step_naming: Arc::new(step_naming),
896            projection_placement: Arc::new(projection_placement),
897        })
898    }
899}
900
901/// Walk the flow `Node`, collect every `Step.ref`, and check that no ref
902/// is unresolved against `routes` (or the default, when one exists).
903fn verify_refs(
904    node: &FlowNode,
905    routes: &HashMap<String, Arc<dyn SpawnerAdapter>>,
906    has_default: bool,
907) -> Result<(), CompileError> {
908    let mut refs: Vec<String> = Vec::new();
909    collect_refs(node, &mut refs);
910    for r in refs {
911        if !routes.contains_key(&r) && !has_default {
912            return Err(CompileError::UnresolvedRef(r));
913        }
914    }
915    Ok(())
916}
917
918fn collect_refs(node: &FlowNode, out: &mut Vec<String>) {
919    match node {
920        FlowNode::Step { ref_, .. } => out.push(ref_.clone()),
921        FlowNode::Seq { children } => {
922            for c in children {
923                collect_refs(c, out);
924            }
925        }
926        FlowNode::Branch { then_, else_, .. } => {
927            collect_refs(then_, out);
928            collect_refs(else_, out);
929        }
930        FlowNode::Fanout { body, .. } => collect_refs(body, out),
931        FlowNode::Loop { body, .. } => collect_refs(body, out),
932        FlowNode::Try { body, catch, .. } => {
933            collect_refs(body, out);
934            collect_refs(catch, out);
935        }
936        FlowNode::Let { .. } => {} // The Let node carries no ref.
937    }
938}
939
940/// GH #21 Phase 2: walk the flow `Node` (same recursion shape as
941/// [`collect_refs`]) and collect every statically-visible `$step_meta.ref`
942/// found inside a Step's `in` **Lit** expr, as `(where_, meta_ref)` pairs
943/// for [`CompileError::UnresolvedMetaRef`] reporting. Non-`Lit` `in`
944/// exprs (e.g. `Expr::Path`) cannot be inspected statically and are
945/// silently skipped — `EngineDispatcher::dispatch` (the `mlua-swarm` core
946/// crate) is the authoritative, loud validation line for those.
947fn collect_step_meta_refs(node: &FlowNode, out: &mut Vec<(String, String)>) {
948    match node {
949        FlowNode::Step { ref_, in_, .. } => {
950            if let Expr::Lit { value } = in_ {
951                if let Some(meta_ref) = static_step_meta_ref(value) {
952                    out.push((format!("Step '{ref_}' $step_meta.ref"), meta_ref));
953                }
954            }
955        }
956        FlowNode::Seq { children } => {
957            for c in children {
958                collect_step_meta_refs(c, out);
959            }
960        }
961        FlowNode::Branch { then_, else_, .. } => {
962            collect_step_meta_refs(then_, out);
963            collect_step_meta_refs(else_, out);
964        }
965        FlowNode::Fanout { body, .. } => collect_step_meta_refs(body, out),
966        FlowNode::Loop { body, .. } => collect_step_meta_refs(body, out),
967        FlowNode::Try { body, catch, .. } => {
968            collect_step_meta_refs(body, out);
969            collect_step_meta_refs(catch, out);
970        }
971        FlowNode::Let { .. } => {} // The Let node carries no `in`.
972    }
973}
974
975/// Extract the `$step_meta.ref` string out of a literal `Step.in` value,
976/// if present and well-formed: `{"$step_meta": {"ref": "<name>", ...},
977/// ...}`. Any other shape (no `$step_meta` key, `ref` absent/null, `ref`
978/// not a string) yields `None` — this is a best-effort static hint only;
979/// a malformed envelope is caught loudly at dispatch time instead (see
980/// `EngineDispatcher::dispatch`'s doc in the `mlua-swarm` core crate).
981fn static_step_meta_ref(value: &Value) -> Option<String> {
982    value
983        .as_object()?
984        .get("$step_meta")?
985        .as_object()?
986        .get("ref")?
987        .as_str()
988        .map(str::to_string)
989}
990
991// ─── GH #50: verdict contract cond↔output-shape lint ───────────────────────
992
993/// The lint kind whose compile-stage disposition a `lints` map may change
994/// (on either the `agents[]` or the `metadata` layer). Deliberately a
995/// single literal and not a loop over [`mlua_swarm_diag::LINT_DECLS`]: at
996/// the compile stage every other kind is a hard error, not a lint, so no
997/// other `CompileError` path is routed through the lint resolver
998/// (design §3 "non-suppressible boundary").
999const UNHANDLED_VERDICT_LINT_KIND: &str = "verdict-value-unhandled";
1000
1001/// What `Compiler::compile` does with an unhandled declared verdict value.
1002#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1003enum UnhandledVerdictGate {
1004    /// Reject the Blueprint with [`CompileError::VerdictValueUnhandled`].
1005    Deny,
1006    /// Surface `tracing::warn!` and keep compiling — the default.
1007    Warn,
1008    /// Say nothing at all: the author declared `allow` for this kind.
1009    Silence,
1010}
1011
1012/// The gate, resolved once per contract-bearing agent.
1013///
1014/// The compile stage reads two of the three [`mlua_swarm_schema::LintSetting`]
1015/// layers — `AgentDef.lints` then `BlueprintMetadata.lints` (there is no
1016/// call-site layer at compile; that one belongs to `bp_doctor`) — under the
1017/// same proximity model: the nearer layer that says anything about the kind
1018/// wins outright, so an agent-level `allow` beats a Blueprint-level `deny`
1019/// for that agent only.
1020#[derive(Debug, Clone, PartialEq, Eq)]
1021struct UnhandledVerdictGates {
1022    /// Agents whose own `lints` decided the gate, by `AgentDef.name`.
1023    per_agent: HashMap<String, UnhandledVerdictGate>,
1024    /// What every other agent gets: the Blueprint layer's outcome.
1025    blueprint: UnhandledVerdictGate,
1026}
1027
1028impl UnhandledVerdictGates {
1029    /// The gate for one agent — its own layer if it declared the kind,
1030    /// otherwise the Blueprint-wide outcome.
1031    fn for_agent(&self, agent: &str) -> UnhandledVerdictGate {
1032        self.per_agent.get(agent).copied().unwrap_or(self.blueprint)
1033    }
1034
1035    /// `true` when no agent can produce a finding — lets the caller skip
1036    /// the fold entirely (the pre-per-agent short circuit, preserved).
1037    fn all_silent(&self) -> bool {
1038        self.blueprint == UnhandledVerdictGate::Silence
1039            && self
1040                .per_agent
1041                .values()
1042                .all(|g| *g == UnhandledVerdictGate::Silence)
1043    }
1044}
1045
1046/// Resolve the compile-stage disposition of `verdict-value-unhandled` per
1047/// agent, from the layers a Blueprint can declare it on:
1048/// `strict_verdict_handling`, `metadata.lints`, and `agents[].lints`.
1049///
1050/// The Blueprint layer is resolved once and reused as the fallback; only
1051/// agents that declare the kind themselves get an entry in
1052/// [`UnhandledVerdictGates::per_agent`].
1053fn resolve_unhandled_verdict_gates(bp: &Blueprint) -> UnhandledVerdictGates {
1054    let strict = bp.metadata.strict_verdict_handling.unwrap_or(false);
1055    let blueprint = resolve_unhandled_verdict_gate(&bp.metadata);
1056    let per_agent = bp
1057        .agents
1058        .iter()
1059        .filter_map(|ad| {
1060            let declared = declared_unhandled_verdict_setting(&ad.lints)?;
1061            Some((
1062                ad.name.clone(),
1063                unhandled_verdict_gate(strict, Some(declared)),
1064            ))
1065        })
1066        .collect();
1067    UnhandledVerdictGates {
1068        per_agent,
1069        blueprint,
1070    }
1071}
1072
1073/// Resolve the Blueprint-wide gate on its own — the layer every agent
1074/// without its own `lints` inherits.
1075fn resolve_unhandled_verdict_gate(metadata: &BlueprintMetadata) -> UnhandledVerdictGate {
1076    unhandled_verdict_gate(
1077        metadata.strict_verdict_handling.unwrap_or(false),
1078        declared_unhandled_verdict_setting(&metadata.lints),
1079    )
1080}
1081
1082/// What one `lints` map says about `verdict-value-unhandled`, applying
1083/// within-layer specificity (exact kind > `category:` > `all`). `None` =
1084/// this layer says nothing, so the next one out decides.
1085///
1086/// Queried with [`mlua_swarm_diag::LintConfig::setting_for`] rather than
1087/// [`mlua_swarm_diag::resolve_level`]: the latter falls back to the kind's
1088/// registry default (`Error`), the level bp_doctor's sibling stage applies
1089/// but the compile stage never does — an undeclared kind keeps the
1090/// historical warn-only default here.
1091fn declared_unhandled_verdict_setting(
1092    lints: &Option<BTreeMap<String, mlua_swarm_schema::LintSetting>>,
1093) -> Option<mlua_swarm_diag::LintSetting> {
1094    use mlua_swarm_diag::{lint_decl, LintConfig};
1095
1096    let cfg = LintConfig::from_pairs(
1097        lints
1098            .as_ref()?
1099            .iter()
1100            .map(|(key, setting)| (key.clone(), diag_lint_setting(*setting))),
1101    );
1102    cfg.setting_for(lint_decl(UNHANDLED_VERDICT_LINT_KIND)?)
1103}
1104
1105/// Fold the winning layer's setting together with the legacy
1106/// `strict_verdict_handling` flag.
1107///
1108/// Union toward `deny`: either spelling saying deny denies, and strict
1109/// wins over an `allow` at *any* layer (the explicit legacy opt-in is
1110/// never silently undone by a broad `all` / `category:` key, nor by one
1111/// agent allowing itself out of it).
1112fn unhandled_verdict_gate(
1113    strict: bool,
1114    declared: Option<mlua_swarm_diag::LintSetting>,
1115) -> UnhandledVerdictGate {
1116    use mlua_swarm_diag::LintSetting;
1117
1118    match declared {
1119        _ if strict => UnhandledVerdictGate::Deny,
1120        Some(LintSetting::Deny) => UnhandledVerdictGate::Deny,
1121        Some(LintSetting::Allow) => UnhandledVerdictGate::Silence,
1122        Some(LintSetting::Warn) | None => UnhandledVerdictGate::Warn,
1123    }
1124}
1125
1126/// Bridge the schema's author-facing enum onto the diag crate's twin — the
1127/// diag crate depends on no other mlua-swarm crate, so each consumer maps
1128/// one onto the other (`bp_doctor` carries the same bridge for its own
1129/// three layers).
1130fn diag_lint_setting(setting: mlua_swarm_schema::LintSetting) -> mlua_swarm_diag::LintSetting {
1131    match setting {
1132        mlua_swarm_schema::LintSetting::Allow => mlua_swarm_diag::LintSetting::Allow,
1133        mlua_swarm_schema::LintSetting::Warn => mlua_swarm_diag::LintSetting::Warn,
1134        mlua_swarm_schema::LintSetting::Deny => mlua_swarm_diag::LintSetting::Deny,
1135    }
1136}
1137
1138/// GH #50: `Blueprint.agents[].verdict` cond↔output-shape lint, run from
1139/// `Compiler::compile` after the routing table is built. Two-pass, same
1140/// shape as [`collect_step_meta_refs`]'s best-effort static walk: Pass 1
1141/// ([`collect_step_outputs`]) builds `Step.out` `Path` string → producing
1142/// `Step.ref_`; Pass 2 ([`collect_verdict_conds`]) walks every
1143/// `Branch`/`Loop` `cond` and resolves each `Eq`/`Ne`/`In` `Path`+`Lit`
1144/// comparison back through the Pass 1 map. Collects every violation before
1145/// returning, then surfaces the first one (mirrors the other
1146/// `Compiler::compile` validation blocks' `Result::Err`-via-`?` pattern).
1147fn verify_verdict_conds(
1148    flow: &FlowNode,
1149    verdict_contracts: &HashMap<String, VerdictContract>,
1150    unhandled_gates: &UnhandledVerdictGates,
1151) -> Result<(), CompileError> {
1152    let mut step_outputs: HashMap<String, String> = HashMap::new();
1153    let mut step_agents: HashMap<String, String> = HashMap::new();
1154    collect_step_outputs_and_agents(flow, &mut step_outputs, &mut step_agents);
1155
1156    let mut errors: Vec<CompileError> = Vec::new();
1157    let mut referenced_values: HashMap<String, std::collections::HashSet<String>> = HashMap::new();
1158    collect_verdict_conds(
1159        flow,
1160        &step_outputs,
1161        verdict_contracts,
1162        &mut referenced_values,
1163        &mut errors,
1164    );
1165    check_unhandled_verdict_values(
1166        verdict_contracts,
1167        &referenced_values,
1168        &step_agents,
1169        unhandled_gates,
1170        &mut errors,
1171    );
1172    match errors.into_iter().next() {
1173        Some(e) => Err(e),
1174        None => Ok(()),
1175    }
1176}
1177
1178/// Pass 1 of [`verify_verdict_conds`]: `Step.out` `Path` (rendered via its
1179/// canonical `Display` string) → the producing `Step.ref_` — mirrors
1180/// [`collect_refs`]'s `Step.ref_` ↔ `AgentDef.name` correspondence (a
1181/// `Step.ref_` directly indexes `Blueprint.agents[].name`, per
1182/// `verify_refs`). Only `Step` nodes produce agent output; `Fanout`'s
1183/// joined-array `out` and `Assign`'s computed `at` are not attributed to
1184/// any single agent and are not inserted here.
1185///
1186/// GH #50 follow-up (issue `33bc825b`): `step_agents` additionally maps
1187/// each `Step.ref_` (= agent name) to the first-seen `Step.ref_` literal,
1188/// so [`check_unhandled_verdict_values`] can attribute a diagnostic to a
1189/// concrete step site. When the same agent is invoked at multiple sites,
1190/// the first-encountered site is retained (best-effort — the diagnostic
1191/// still identifies the offending agent uniquely).
1192fn collect_step_outputs_and_agents(
1193    node: &FlowNode,
1194    out: &mut HashMap<String, String>,
1195    step_agents: &mut HashMap<String, String>,
1196) {
1197    match node {
1198        FlowNode::Step {
1199            ref_,
1200            out: out_expr,
1201            ..
1202        } => {
1203            if let Expr::Path { at } = out_expr {
1204                out.insert(at.to_string(), ref_.clone());
1205            }
1206            step_agents
1207                .entry(ref_.clone())
1208                .or_insert_with(|| ref_.clone());
1209        }
1210        FlowNode::Seq { children } => {
1211            for c in children {
1212                collect_step_outputs_and_agents(c, out, step_agents);
1213            }
1214        }
1215        FlowNode::Branch { then_, else_, .. } => {
1216            collect_step_outputs_and_agents(then_, out, step_agents);
1217            collect_step_outputs_and_agents(else_, out, step_agents);
1218        }
1219        FlowNode::Fanout { body, .. } => collect_step_outputs_and_agents(body, out, step_agents),
1220        FlowNode::Loop { body, .. } => collect_step_outputs_and_agents(body, out, step_agents),
1221        FlowNode::Try { body, catch, .. } => {
1222            collect_step_outputs_and_agents(body, out, step_agents);
1223            collect_step_outputs_and_agents(catch, out, step_agents);
1224        }
1225        FlowNode::Let { .. } => {} // The Let node produces no agent output.
1226    }
1227}
1228
1229/// Pass 2 of [`verify_verdict_conds`]: recurse through the flow the same
1230/// way [`collect_refs`] does, and for every `Branch`/`Loop` node lint its
1231/// own `cond` field via [`lint_cond_expr`] (in addition to recursing into
1232/// `then_`/`else_`/`body`).
1233fn collect_verdict_conds(
1234    node: &FlowNode,
1235    step_outputs: &HashMap<String, String>,
1236    verdict_contracts: &HashMap<String, VerdictContract>,
1237    referenced_values: &mut HashMap<String, std::collections::HashSet<String>>,
1238    errors: &mut Vec<CompileError>,
1239) {
1240    match node {
1241        FlowNode::Branch { cond, then_, else_ } => {
1242            lint_cond_expr(
1243                cond,
1244                "Branch cond",
1245                step_outputs,
1246                verdict_contracts,
1247                referenced_values,
1248                errors,
1249            );
1250            collect_verdict_conds(
1251                then_,
1252                step_outputs,
1253                verdict_contracts,
1254                referenced_values,
1255                errors,
1256            );
1257            collect_verdict_conds(
1258                else_,
1259                step_outputs,
1260                verdict_contracts,
1261                referenced_values,
1262                errors,
1263            );
1264        }
1265        FlowNode::Loop { cond, body, .. } => {
1266            lint_cond_expr(
1267                cond,
1268                "Loop cond",
1269                step_outputs,
1270                verdict_contracts,
1271                referenced_values,
1272                errors,
1273            );
1274            collect_verdict_conds(
1275                body,
1276                step_outputs,
1277                verdict_contracts,
1278                referenced_values,
1279                errors,
1280            );
1281        }
1282        FlowNode::Seq { children } => {
1283            for c in children {
1284                collect_verdict_conds(
1285                    c,
1286                    step_outputs,
1287                    verdict_contracts,
1288                    referenced_values,
1289                    errors,
1290                );
1291            }
1292        }
1293        FlowNode::Fanout { body, .. } => collect_verdict_conds(
1294            body,
1295            step_outputs,
1296            verdict_contracts,
1297            referenced_values,
1298            errors,
1299        ),
1300        FlowNode::Try { body, catch, .. } => {
1301            collect_verdict_conds(
1302                body,
1303                step_outputs,
1304                verdict_contracts,
1305                referenced_values,
1306                errors,
1307            );
1308            collect_verdict_conds(
1309                catch,
1310                step_outputs,
1311                verdict_contracts,
1312                referenced_values,
1313                errors,
1314            );
1315        }
1316        FlowNode::Step { .. } | FlowNode::Let { .. } => {}
1317    }
1318}
1319
1320/// Lint one `cond` `Expr` tree for [`collect_verdict_conds`]: recurses into
1321/// `And`/`Or`/`Not` (the only boolean combinators a verdict comparison can
1322/// be nested under) and, for every `Eq`/`Ne` leaf whose operands are a
1323/// `Path` + `Lit` pair (either order — see [`path_lit_operands`]), or every
1324/// `In` leaf whose `needle` is a `Path` and `haystack` is a `Lit` JSON
1325/// array, resolves + validates via [`resolve_and_check`]. Any other `Expr`
1326/// shape (arithmetic, `Exists`, `CallExtern`, a non-`Path`/`Lit` `Eq`/`Ne`
1327/// pair, ...) is not a verdict comparison and is skipped.
1328fn lint_cond_expr(
1329    expr: &Expr,
1330    where_: &str,
1331    step_outputs: &HashMap<String, String>,
1332    verdict_contracts: &HashMap<String, VerdictContract>,
1333    referenced_values: &mut HashMap<String, std::collections::HashSet<String>>,
1334    errors: &mut Vec<CompileError>,
1335) {
1336    match expr {
1337        Expr::Eq { lhs, rhs } | Expr::Ne { lhs, rhs } => {
1338            if let Some((path, lit)) = path_lit_operands(lhs, rhs) {
1339                resolve_and_check(
1340                    path,
1341                    &[lit],
1342                    where_,
1343                    step_outputs,
1344                    verdict_contracts,
1345                    referenced_values,
1346                    errors,
1347                );
1348            }
1349        }
1350        Expr::In { needle, haystack } => {
1351            if let (
1352                Expr::Path { at },
1353                Expr::Lit {
1354                    value: Value::Array(items),
1355                },
1356            ) = (needle.as_ref(), haystack.as_ref())
1357            {
1358                let lits: Vec<&Value> = items.iter().collect();
1359                resolve_and_check(
1360                    at,
1361                    &lits,
1362                    where_,
1363                    step_outputs,
1364                    verdict_contracts,
1365                    referenced_values,
1366                    errors,
1367                );
1368            }
1369        }
1370        Expr::And { args } | Expr::Or { args } => {
1371            for a in args {
1372                lint_cond_expr(
1373                    a,
1374                    where_,
1375                    step_outputs,
1376                    verdict_contracts,
1377                    referenced_values,
1378                    errors,
1379                );
1380            }
1381        }
1382        Expr::Not { arg } => lint_cond_expr(
1383            arg,
1384            where_,
1385            step_outputs,
1386            verdict_contracts,
1387            referenced_values,
1388            errors,
1389        ),
1390        _ => {}
1391    }
1392}
1393
1394/// Extract a `(Path, Lit value)` pair out of an `Eq`/`Ne`'s two operands,
1395/// regardless of which side the `Path` is on. `None` when the pairing is
1396/// not exactly one `Path` + one `Lit` (e.g. both are `Path`, or either is a
1397/// compound expr) — those are not statically resolvable to a single
1398/// literal token and are left for `EngineDispatcher`'s runtime eval.
1399fn path_lit_operands<'a>(lhs: &'a Expr, rhs: &'a Expr) -> Option<(&'a Path, &'a Value)> {
1400    match (lhs, rhs) {
1401        (Expr::Path { at }, Expr::Lit { value }) => Some((at, value)),
1402        (Expr::Lit { value }, Expr::Path { at }) => Some((at, value)),
1403        _ => None,
1404    }
1405}
1406
1407/// Resolve `path` back to a producing step — either as the bare step
1408/// output (`channel: Body`) or, via the literal `.parts.verdict` suffix
1409/// (`channel: Part` — the "verdict" part name is a literal, per the
1410/// "Returning verdicts to drive BP flow" guide's Pattern B), as that
1411/// step's staged verdict part. A `path` that resolves to neither shape
1412/// against any known step output is skipped silently (best-effort static
1413/// lint only, same posture as [`collect_step_meta_refs`]).
1414///
1415/// When the resolved agent declares a [`VerdictContract`], validates the
1416/// resolved channel against it first (a mismatch short-circuits — the
1417/// value comparison is moot once the channel itself is wrong) and then
1418/// every entry of `lits` against `contract.values`, pushing at most one
1419/// `CompileError` per violation. When the resolved agent declares no
1420/// contract, emits a `tracing::warn!` only (GH #50's opt-in requirement).
1421fn resolve_and_check(
1422    path: &Path,
1423    lits: &[&Value],
1424    where_: &str,
1425    step_outputs: &HashMap<String, String>,
1426    verdict_contracts: &HashMap<String, VerdictContract>,
1427    referenced_values: &mut HashMap<String, std::collections::HashSet<String>>,
1428    errors: &mut Vec<CompileError>,
1429) {
1430    let path_str = path.to_string();
1431    let (agent, actual_shape) = if let Some(agent) = step_outputs.get(&path_str) {
1432        (agent, "body")
1433    } else if let Some(stripped) = path_str.strip_suffix(".parts.verdict") {
1434        match step_outputs.get(stripped) {
1435            Some(agent) => (agent, "part"),
1436            None => return,
1437        }
1438    } else {
1439        return;
1440    };
1441
1442    let Some(contract) = verdict_contracts.get(agent) else {
1443        tracing::warn!(
1444            agent = %agent,
1445            where_ = %where_,
1446            "cond references agent output but no verdict contract declared"
1447        );
1448        return;
1449    };
1450
1451    let expected_channel = match contract.channel {
1452        VerdictChannel::Body => "body",
1453        VerdictChannel::Part => "part",
1454    };
1455    if expected_channel != actual_shape {
1456        errors.push(CompileError::VerdictChannelMismatch {
1457            where_: where_.to_string(),
1458            agent: agent.clone(),
1459            expected_channel: expected_channel.to_string(),
1460            actual_shape: actual_shape.to_string(),
1461        });
1462        return;
1463    }
1464
1465    for lit in lits {
1466        let value_str = lit
1467            .as_str()
1468            .map(str::to_string)
1469            .unwrap_or_else(|| lit.to_string());
1470        if !contract.values.iter().any(|v| v == &value_str) {
1471            errors.push(CompileError::VerdictValueNotInContract {
1472                where_: where_.to_string(),
1473                agent: agent.clone(),
1474                value: value_str.clone(),
1475                values: contract.values.clone(),
1476            });
1477        }
1478        // GH #50 follow-up (issue `33bc825b`): record the referenced value
1479        // regardless of contract membership. `VerdictValueNotInContract`
1480        // already caught the out-of-set case above; recording here still
1481        // helps future variants that widen the set later. The value string
1482        // is normalized identically to the membership check for symmetric
1483        // comparison in `check_unhandled_verdict_values`.
1484        referenced_values
1485            .entry(agent.clone())
1486            .or_default()
1487            .insert(value_str);
1488    }
1489}
1490
1491/// GH #50 follow-up (issue `33bc825b`): reverse-direction lint.
1492///
1493/// For every agent that declares a [`VerdictContract`], check that every
1494/// entry of `contract.values` was referenced by at least one downstream
1495/// `Branch`/`Loop` `cond` `Lit` (as collected into `referenced_values` by
1496/// [`resolve_and_check`] during the forward pass). Any declared value
1497/// that no cond references is a `verdict_value` the flow author declared
1498/// but forgot to write a handler for.
1499///
1500/// The gate is per finding-owning agent ([`UnhandledVerdictGates::for_agent`]),
1501/// so one agent's declared level never decides another's.
1502///
1503/// Under [`UnhandledVerdictGate::Deny`] (`strict_verdict_handling: true`,
1504/// or a `{"verdict-value-unhandled": "deny"}` entry on the agent or the
1505/// Blueprint, see [`resolve_unhandled_verdict_gates`]), every unhandled
1506/// value pushes a [`CompileError::VerdictValueUnhandled`] onto `errors` and
1507/// [`verify_verdict_conds`] surfaces the first one, rejecting the compile.
1508/// Under the default [`UnhandledVerdictGate::Warn`], unhandled values only
1509/// surface via `tracing::warn!` — existing Blueprints that intentionally
1510/// leave some verdict values as silent-pass informational tokens keep
1511/// compiling unchanged (back-compat with GH #50's opt-in posture) — and
1512/// under [`UnhandledVerdictGate::Silence`] (an author-declared `allow`)
1513/// not even that.
1514fn check_unhandled_verdict_values(
1515    verdict_contracts: &HashMap<String, VerdictContract>,
1516    referenced_values: &HashMap<String, std::collections::HashSet<String>>,
1517    step_agents: &HashMap<String, String>,
1518    unhandled_gates: &UnhandledVerdictGates,
1519    errors: &mut Vec<CompileError>,
1520) {
1521    if unhandled_gates.all_silent() {
1522        return;
1523    }
1524    for finding in fold_unhandled_verdict_values(verdict_contracts, referenced_values, step_agents)
1525    {
1526        let gate = unhandled_gates.for_agent(&finding.agent);
1527        match gate {
1528            UnhandledVerdictGate::Deny => errors.push(CompileError::VerdictValueUnhandled {
1529                agent: finding.agent,
1530                value: finding.value,
1531                declared_values: finding.declared_values,
1532                step_ref: finding.step_ref,
1533            }),
1534            UnhandledVerdictGate::Warn => tracing::warn!(
1535                agent = %finding.agent,
1536                value = %finding.value,
1537                step_ref = %finding.step_ref,
1538                "declared verdict value has no downstream cond handler; \
1539                 declare `metadata.lints = {{\"verdict-value-unhandled\": \"deny\"}}` \
1540                 to reject at compile"
1541            ),
1542            // This agent declared `allow`; another one did not, which is
1543            // why the fold ran at all (`all_silent` returned above only
1544            // when nothing anywhere could report).
1545            UnhandledVerdictGate::Silence => {}
1546        }
1547    }
1548}
1549
1550/// One declared `verdict.values` entry that no downstream `Branch`/`Loop`
1551/// `cond` ever compares against — the reverse-direction lint's finding,
1552/// as data.
1553///
1554/// Exists so the same check can drive two very different surfaces without
1555/// a second implementation: the compile gate
1556/// ([`check_unhandled_verdict_values`], which turns a finding into a
1557/// `CompileError` under `strict_verdict_handling` and a `tracing::warn!`
1558/// otherwise) and the report-only `bp_doctor` `verdict_contract_lint`
1559/// family (via [`unhandled_verdict_values`]).
1560#[derive(Debug, Clone, PartialEq, Eq)]
1561pub struct UnhandledVerdictValue {
1562    /// The contract-bearing agent (= `AgentDef.name` = `Step.ref_`).
1563    pub agent: String,
1564    /// The declared value nothing handles.
1565    pub value: String,
1566    /// The agent's full declared token set, for the diagnostic's context.
1567    pub declared_values: Vec<String>,
1568    /// The first flow site that invokes `agent`, for attribution.
1569    pub step_ref: String,
1570}
1571
1572/// Report-only projection of the reverse-direction verdict lint: run both
1573/// passes [`verify_verdict_conds`] runs and return the unhandled declared
1574/// values as data instead of turning the first one into a `CompileError`.
1575///
1576/// Callable on an already-registered Blueprint with no `SpawnerRegistry`
1577/// and no compile — the `bp_doctor` `verdict_contract_lint` family's
1578/// producer. Forward-direction violations (`VerdictChannelMismatch` /
1579/// `VerdictValueNotInContract`) are the compile gate's business and are
1580/// deliberately dropped here: they already hard-fail `bp_build`, so
1581/// re-reporting them as advisory findings would double-count.
1582///
1583/// A Blueprint whose flow declares contracts but has no `Branch`/`Loop`
1584/// at all yields one finding per declared value — the shape that reads as
1585/// "this contract is decorative", and the earliest signal that a
1586/// `channel` was declared without anything downstream actually reading
1587/// it.
1588pub fn unhandled_verdict_values(
1589    flow: &FlowNode,
1590    verdict_contracts: &HashMap<String, VerdictContract>,
1591) -> Vec<UnhandledVerdictValue> {
1592    let mut step_outputs: HashMap<String, String> = HashMap::new();
1593    let mut step_agents: HashMap<String, String> = HashMap::new();
1594    collect_step_outputs_and_agents(flow, &mut step_outputs, &mut step_agents);
1595
1596    let mut referenced_values: HashMap<String, std::collections::HashSet<String>> = HashMap::new();
1597    let mut discarded_errors: Vec<CompileError> = Vec::new();
1598    collect_verdict_conds(
1599        flow,
1600        &step_outputs,
1601        verdict_contracts,
1602        &mut referenced_values,
1603        &mut discarded_errors,
1604    );
1605    fold_unhandled_verdict_values(verdict_contracts, &referenced_values, &step_agents)
1606}
1607
1608/// One agent whose entire declared `verdict.values` set went unread — the
1609/// per-agent aggregate of [`UnhandledVerdictValue`]. Signals that the
1610/// contract is decorative: the step declares a verdict, but every declared
1611/// token is unhandled downstream, so the gate cannot halt the flow.
1612///
1613/// Separate from [`UnhandledVerdictValue`] because a normal Blueprint
1614/// always leaks one per-value finding per agent (the halt gate only reads
1615/// the halt token, so PASS is structurally unhandled). That baseline noise
1616/// hides the actual defect this variant catches — the whole gate being
1617/// dropped (e.g. `2db863e` opt-OUT authoring surviving the `bafe47d4`
1618/// opt-in flip). Consumers surface both: per-value stays for parity with
1619/// `strict_verdict_handling`, per-agent adds a WARN whose count equals the
1620/// number of agents whose gate is fully dead.
1621#[derive(Debug, Clone, PartialEq, Eq)]
1622pub struct AgentContractUnread {
1623    /// The contract-bearing agent (= `AgentDef.name` = `Step.ref_`).
1624    pub agent: String,
1625    /// The full declared token set — every one of these is unread.
1626    pub declared_values: Vec<String>,
1627    /// The first flow site that invokes `agent`, for attribution.
1628    pub step_ref: String,
1629}
1630
1631/// Per-agent aggregate of [`unhandled_verdict_values`]: return one entry
1632/// per agent whose entire declared `verdict.values` set went unhandled.
1633///
1634/// Called by the `bp_doctor` `verdict_contract_lint` family alongside the
1635/// per-value producer; the two views coexist. Agents with a partially
1636/// handled contract (any single value read by a cond) contribute nothing
1637/// here — the per-value findings already point at the specific gap.
1638///
1639/// Stable order (agent name sort) mirrors [`fold_unhandled_verdict_values`]
1640/// so the `bp_doctor` findings array is reproducible between calls.
1641pub fn agents_with_all_verdict_values_unread(
1642    flow: &FlowNode,
1643    verdict_contracts: &HashMap<String, VerdictContract>,
1644) -> Vec<AgentContractUnread> {
1645    let per_value = unhandled_verdict_values(flow, verdict_contracts);
1646    let mut unread_counts: HashMap<String, usize> = HashMap::new();
1647    for finding in &per_value {
1648        *unread_counts.entry(finding.agent.clone()).or_default() += 1;
1649    }
1650    let mut agents: Vec<&String> = verdict_contracts.keys().collect();
1651    agents.sort();
1652    let mut out = Vec::new();
1653    for agent in agents {
1654        let contract = &verdict_contracts[agent];
1655        let declared = contract.values.len();
1656        if declared == 0 {
1657            continue;
1658        }
1659        let unread = unread_counts.get(agent).copied().unwrap_or(0);
1660        if unread != declared {
1661            continue;
1662        }
1663        // Attribute to the first step that invokes this agent, matching the
1664        // per-value producer's `step_ref` field so downstream renderers can
1665        // cross-reference the two finding sets by agent + step.
1666        let step_ref = per_value
1667            .iter()
1668            .find(|f| &f.agent == agent)
1669            .map(|f| f.step_ref.clone())
1670            .unwrap_or_else(|| agent.clone());
1671        out.push(AgentContractUnread {
1672            agent: agent.clone(),
1673            declared_values: contract.values.clone(),
1674            step_ref,
1675        });
1676    }
1677    out
1678}
1679
1680/// The shared core of [`check_unhandled_verdict_values`] and
1681/// [`unhandled_verdict_values`]: given the two passes' output, fold out
1682/// the declared values nothing references.
1683///
1684/// Iterates in a stable order (sorted by agent name, then declared-value
1685/// order) so the first `VerdictValueUnhandled` error surfaced under
1686/// strict mode is deterministic across HashMap hash seeds, and so the
1687/// `bp_doctor` family's findings array is reproducible between calls.
1688/// This mirrors GH #50's other lint diagnostics, which are stable because
1689/// they walk the flow tree in source order.
1690fn fold_unhandled_verdict_values(
1691    verdict_contracts: &HashMap<String, VerdictContract>,
1692    referenced_values: &HashMap<String, std::collections::HashSet<String>>,
1693    step_agents: &HashMap<String, String>,
1694) -> Vec<UnhandledVerdictValue> {
1695    let mut agents: Vec<&String> = verdict_contracts.keys().collect();
1696    agents.sort();
1697    let mut findings = Vec::new();
1698    for agent in agents {
1699        let contract = &verdict_contracts[agent];
1700        let referenced = referenced_values.get(agent);
1701        let step_ref = step_agents
1702            .get(agent)
1703            .cloned()
1704            .unwrap_or_else(|| agent.clone());
1705        for value in &contract.values {
1706            let handled = referenced.map(|set| set.contains(value)).unwrap_or(false);
1707            if handled {
1708                continue;
1709            }
1710            findings.push(UnhandledVerdictValue {
1711                agent: agent.clone(),
1712                value: value.clone(),
1713                declared_values: contract.values.clone(),
1714                step_ref: step_ref.clone(),
1715            });
1716        }
1717    }
1718    findings
1719}
1720
1721// ─── CompiledAgentTable ───────────────────────────────────────────────────────
1722
1723/// The compile result: an `agent name → SpawnerAdapter` lookup table.
1724///
1725/// Looks `routes` up by `ctx.agent` (the flow.ir `Step.ref`) and hands
1726/// the spawn to the matching `SpawnerAdapter`. If the name is not
1727/// registered and a `default` is configured, the default is used; if
1728/// there is no default, `SpawnError::NotRegistered` is returned.
1729///
1730/// Layer wrapping (`AuditMiddleware` / `MainAIMiddleware` and friends) is
1731/// not this type's concern — that is done separately in
1732/// `service::linker::link`.
1733pub struct CompiledAgentTable {
1734    pub(crate) routes: HashMap<String, Arc<dyn SpawnerAdapter>>,
1735    pub(crate) default: Option<Arc<dyn SpawnerAdapter>>,
1736    /// GH #50: `AgentDef.name` → declared `VerdictContract`, for every
1737    /// agent that declared one (built by `Compiler::compile`, alongside
1738    /// `routes`). Backs the submit-time enforcement point (a follow-up).
1739    pub(crate) verdict_contracts: HashMap<String, VerdictContract>,
1740}
1741
1742impl CompiledAgentTable {
1743    /// Whether the given agent name is registered in the table — i.e.,
1744    /// whether its spawner has been resolved.
1745    pub fn has_route(&self, agent: &str) -> bool {
1746        self.routes.contains_key(agent)
1747    }
1748    /// List every resolved agent name.
1749    pub fn routed_agents(&self) -> Vec<String> {
1750        self.routes.keys().cloned().collect()
1751    }
1752    /// GH #50: the declared [`VerdictContract`] for `agent`, if any —
1753    /// `None` both when `agent` is unresolved and when it resolved but
1754    /// declared no contract (opt-in; see `AgentDef::verdict`'s doc).
1755    pub fn verdict_contract_for(&self, agent: &str) -> Option<&VerdictContract> {
1756        self.verdict_contracts.get(agent)
1757    }
1758}
1759
1760#[async_trait]
1761impl SpawnerAdapter for CompiledAgentTable {
1762    async fn spawn(
1763        &self,
1764        engine: &Engine,
1765        ctx: &Ctx,
1766        task_id: StepId,
1767        attempt: u32,
1768        token: CapToken,
1769    ) -> Result<Box<dyn Worker>, SpawnError> {
1770        let sp = self
1771            .routes
1772            .get(&ctx.agent)
1773            .cloned()
1774            .or_else(|| self.default.clone())
1775            .ok_or_else(|| SpawnError::NotRegistered(ctx.agent.clone()))?;
1776        sp.spawn(engine, ctx, task_id, attempt, token).await
1777    }
1778}
1779
1780// ─── default factories (three variants) ───────────────────────────────────
1781
1782/// Factory for `AgentKind::Subprocess`. Turns the spec into a
1783/// [`ProcessSpawner`].
1784///
1785/// Naming convention: `<WorkerIMPL><AdapterType>SpawnerFactory`. Factory
1786/// names carry both the worker implementation and the host adapter so
1787/// they are not confused with each other; the old
1788/// `ShellSpawnerFactory` was renamed to this.
1789///
1790/// Spec shape:
1791/// ```jsonc
1792/// { "program": "agent-block", "args": ["-s","s.lua"],
1793///   "use_stdin": true,                       // optional, default = true
1794///   "stream_mode": "ndjson_lines" | "sse_events" | "length_prefixed" | null  // optional, default = null (plain)
1795/// }
1796/// ```
1797///
1798/// # GH #83 — EmbedAgent template mode
1799///
1800/// When the build `hint` carries a `subprocess_template` key (synthesized
1801/// by `Compiler::compile` from a resolved `Runner::Subprocess` — see
1802/// [`resolve_subprocess_template_hint`]), the factory switches to the
1803/// EmbedAgent path instead: it bakes `agent_def.profile`
1804/// (system_prompt / model / tools, same compile-time bake shape as
1805/// `OperatorSpawnerFactory`), validates the template's placeholder tokens
1806/// against the closed set, and returns a `ProcessSpawner` whose `embed`
1807/// field drives the render → exec → normalize spawn. The spec-based
1808/// shape above stays byte-for-byte untouched when no such hint is
1809/// present.
1810pub struct SubprocessProcessSpawnerFactory;
1811
1812impl SpawnerFactoryKind for SubprocessProcessSpawnerFactory {
1813    const KIND: AgentKind = AgentKind::Subprocess;
1814    type Worker = crate::worker::process_spawner::ProcessWorker;
1815}
1816
1817/// GH #83 — hint key carrying the resolved [`SubprocessDef`] template
1818/// (synthesized at compile time, see [`resolve_subprocess_template_hint`]).
1819pub const SUBPROCESS_TEMPLATE_HINT_KEY: &str = "subprocess_template";
1820/// GH #83 — hint key carrying the `Runner::Subprocess` overrides.
1821pub const SUBPROCESS_OVERRIDES_HINT_KEY: &str = "subprocess_overrides";
1822
1823// GH #86 note — no `agent_block_tools` build hint exists, deliberately.
1824// `Runner::AgentBlockInProcess.tools` already reaches the AgentBlock
1825// factory as `profile.tools`, projected from the immutable `BoundAgent`
1826// snapshot by `project_bound_agent_for_legacy_factories` above. Re-deriving
1827// it here (the shape GH #83's Subprocess sibling uses, which has no such
1828// projection) would re-run `resolve_runner` against the LIVE Blueprint and
1829// so let a `Blueprint.runners` edit change a pinned Run's enforced grant on
1830// resume — exactly the drift `compile_bound` exists to prevent.
1831
1832/// GH #83 — reject any `{ident}` token outside the closed placeholder
1833/// set. Only lowercase-identifier tokens (`[a-z_]+`) are placeholder
1834/// candidates; other brace contents (e.g. JSON literals like
1835/// `{"result": 1}` inside a `sh -c` one-liner) are legal template text.
1836fn validate_embed_placeholders(s: &str, where_: &str) -> Result<(), String> {
1837    let mut rest = s;
1838    while let Some(start) = rest.find('{') {
1839        let after = &rest[start + 1..];
1840        let Some(end) = after.find('}') else {
1841            break;
1842        };
1843        let token = &after[..end];
1844        let is_candidate =
1845            !token.is_empty() && token.chars().all(|c| c.is_ascii_lowercase() || c == '_');
1846        if is_candidate {
1847            if !crate::worker::process_spawner::EMBED_PLACEHOLDERS.contains(&token) {
1848                return Err(format!(
1849                    "unknown placeholder '{{{token}}}' in {where_}; closed set is \
1850                     {{system, system_file, prompt, model, tools_csv, work_dir, task_id, attempt}}"
1851                ));
1852            }
1853            rest = &after[end + 1..];
1854        } else {
1855            // Literal brace text — keep scanning right after the '{' so a
1856            // placeholder nested inside (e.g. a JSON-wrapped stdin like
1857            // `{"task": "{prompt}"}`) is still validated. Mirrors the
1858            // spawn-time render scan in `EmbedVars::render`.
1859            rest = after;
1860        }
1861    }
1862    Ok(())
1863}
1864
1865/// GH #83 — compile-time resolution of an agent's `Runner::Subprocess`
1866/// declaration into the synthesized build hint the
1867/// `SubprocessProcessSpawnerFactory` consumes. Returns `Ok(None)` when
1868/// the agent resolves to no Runner or to a non-Subprocess backend — the
1869/// caller then keeps the historical spec-based hint untouched.
1870fn resolve_subprocess_template_hint(
1871    bp: &Blueprint,
1872    ad: &AgentDef,
1873) -> Result<Option<Value>, CompileError> {
1874    let invalid = |msg: String| CompileError::InvalidSpec {
1875        name: ad.name.clone(),
1876        msg,
1877    };
1878    let runner = mlua_swarm_schema::resolve_runner(bp, ad).map_err(|e| invalid(e.to_string()))?;
1879    let Some(Runner::Subprocess {
1880        template,
1881        overrides,
1882    }) = runner
1883    else {
1884        return Ok(None);
1885    };
1886    let def = bp
1887        .subprocesses
1888        .iter()
1889        .find(|d| d.name == template)
1890        .ok_or_else(|| {
1891            let mut names: Vec<&str> = bp.subprocesses.iter().map(|d| d.name.as_str()).collect();
1892            names.sort_unstable();
1893            invalid(format!(
1894                "Runner::Subprocess template '{template}' not found in \
1895                 Blueprint.subprocesses (defined: [{}])",
1896                names.join(", ")
1897            ))
1898        })?;
1899    Ok(Some(serde_json::json!({
1900        SUBPROCESS_TEMPLATE_HINT_KEY: def,
1901        SUBPROCESS_OVERRIDES_HINT_KEY: overrides,
1902    })))
1903}
1904
1905impl SubprocessProcessSpawnerFactory {
1906    /// GH #83 — the EmbedAgent template build path (see the struct doc).
1907    /// Returns the concrete [`ProcessSpawner`] so unit tests can inspect
1908    /// the baked [`EmbedTemplate`]; `SpawnerFactory::build` wraps it in
1909    /// the trait `Arc`.
1910    fn build_embed(
1911        agent_def: &AgentDef,
1912        template: &Value,
1913        overrides: Option<&Value>,
1914    ) -> Result<ProcessSpawner, CompileError> {
1915        use crate::worker::process_spawner::EmbedTemplate;
1916        use mlua_swarm_schema::{SubprocessDef, SubprocessOverrides};
1917
1918        let agent_name = &agent_def.name;
1919        let invalid = |msg: String| CompileError::InvalidSpec {
1920            name: agent_name.to_string(),
1921            msg,
1922        };
1923        let def: SubprocessDef = serde_json::from_value(template.clone())
1924            .map_err(|e| invalid(format!("subprocess_template hint: {e}")))?;
1925        let overrides: SubprocessOverrides = match overrides {
1926            Some(v) => serde_json::from_value(v.clone())
1927                .map_err(|e| invalid(format!("subprocess_overrides hint: {e}")))?,
1928            None => SubprocessOverrides::default(),
1929        };
1930
1931        if def.argv.is_empty() {
1932            return Err(invalid(format!(
1933                "SubprocessDef '{}': argv must not be empty",
1934                def.name
1935            )));
1936        }
1937        // Closed-set placeholder validation across every template string.
1938        for (i, a) in def.argv.iter().enumerate() {
1939            validate_embed_placeholders(a, &format!("argv[{i}]")).map_err(&invalid)?;
1940        }
1941        if let Some(stdin) = &def.stdin {
1942            validate_embed_placeholders(stdin, "stdin").map_err(&invalid)?;
1943        }
1944        for (k, v) in &def.env {
1945            validate_embed_placeholders(v, &format!("env['{k}']")).map_err(&invalid)?;
1946        }
1947        if let Some(cwd) = &def.cwd {
1948            validate_embed_placeholders(cwd, "cwd").map_err(&invalid)?;
1949        }
1950        let stream_mode = match def.stream_mode.as_deref() {
1951            Some("ndjson_lines") => Some(StreamMode::NdjsonLines),
1952            Some("sse_events") => Some(StreamMode::SseEvents),
1953            Some("length_prefixed") => Some(StreamMode::LengthPrefixed),
1954            Some(other) => return Err(invalid(format!("unknown stream_mode: {other}"))),
1955            None => None,
1956        };
1957        if def.timeout_ms == Some(0) {
1958            return Err(invalid(format!(
1959                "SubprocessDef '{}': timeout_ms must be greater than 0 \
1960                 (omit the field for no per-attempt limit)",
1961                def.name
1962            )));
1963        }
1964        if let Some(output) = &def.output {
1965            if stream_mode.is_some() {
1966                return Err(invalid(format!(
1967                    "SubprocessDef '{}': output normalization is a plain-mode \
1968                     declaration; remove either `output` or `stream_mode`",
1969                    def.name
1970                )));
1971            }
1972            if let Some(format) = output.format.as_deref() {
1973                if format != "json" {
1974                    return Err(invalid(format!(
1975                        "SubprocessDef '{}': unknown output.format '{format}' \
1976                         (supported: \"json\")",
1977                        def.name
1978                    )));
1979                }
1980            }
1981            if let Some(ptr) = output.result_ptr.as_deref() {
1982                if !ptr.starts_with('/') {
1983                    return Err(invalid(format!(
1984                        "SubprocessDef '{}': output.result_ptr '{ptr}' is not a \
1985                         JSON Pointer (RFC 6901 — must start with '/')",
1986                        def.name
1987                    )));
1988                }
1989            }
1990            if let Some(ok_from) = output.ok_from.as_deref() {
1991                if ok_from != "exit_code" && !ok_from.starts_with('/') {
1992                    return Err(invalid(format!(
1993                        "SubprocessDef '{}': output.ok_from '{ok_from}' must be \
1994                         \"exit_code\" or a JSON Pointer (starting with '/')",
1995                        def.name
1996                    )));
1997                }
1998            }
1999        }
2000
2001        // Compile-time profile bake — same shape as OperatorSpawnerFactory,
2002        // with Runner::Subprocess overrides winning over the profile.
2003        let profile = agent_def.profile.as_ref();
2004        let system_prompt = profile
2005            .map(|p| p.system_prompt.clone())
2006            .filter(|s| !s.is_empty());
2007        let model = overrides
2008            .model
2009            .clone()
2010            .or_else(|| profile.and_then(|p| p.model.clone()));
2011        let tools: Vec<String> = if overrides.tools.is_empty() {
2012            profile.map(|p| p.tools.clone()).unwrap_or_default()
2013        } else {
2014            overrides.tools.clone()
2015        };
2016        // overrides.cwd wins over the template's own cwd.
2017        let cwd = overrides.cwd.clone().or_else(|| def.cwd.clone());
2018        if let Some(c) = &cwd {
2019            validate_embed_placeholders(c, "overrides.cwd").map_err(&invalid)?;
2020        }
2021
2022        let program = def.argv[0].clone();
2023        let sp = ProcessSpawner {
2024            program,
2025            args: Vec::new(),
2026            use_stdin: def.stdin.is_some(),
2027            stream_mode,
2028            embed: Some(EmbedTemplate {
2029                argv: def.argv,
2030                stdin: def.stdin,
2031                env: def.env,
2032                cwd,
2033                output: def.output,
2034                system_prompt,
2035                model,
2036                tools_csv: tools.join(","),
2037                timeout: def.timeout_ms.map(std::time::Duration::from_millis),
2038            }),
2039        };
2040        Ok(sp)
2041    }
2042}
2043
2044impl SpawnerFactory for SubprocessProcessSpawnerFactory {
2045    fn build(
2046        &self,
2047        agent_def: &AgentDef,
2048        hint: Option<&Value>,
2049    ) -> Result<Arc<dyn SpawnerAdapter>, CompileError> {
2050        // GH #83: EmbedAgent template mode when the compile-synthesized
2051        // hint is present; the spec-based path below is byte-for-byte
2052        // unchanged otherwise.
2053        if let Some(template) = hint.and_then(|h| h.get(SUBPROCESS_TEMPLATE_HINT_KEY)) {
2054            let overrides = hint.and_then(|h| h.get(SUBPROCESS_OVERRIDES_HINT_KEY));
2055            return Self::build_embed(agent_def, template, overrides).map(|sp| {
2056                let arc: Arc<dyn SpawnerAdapter> = Arc::new(sp);
2057                arc
2058            });
2059        }
2060        let agent_name = &agent_def.name;
2061        let spec = &agent_def.spec;
2062        let invalid = |msg: String| CompileError::InvalidSpec {
2063            name: agent_name.to_string(),
2064            msg,
2065        };
2066        let program = spec
2067            .get("program")
2068            .and_then(|v| v.as_str())
2069            .ok_or_else(|| invalid("shell spec: 'program' (string) required".into()))?
2070            .to_string();
2071        let args: Vec<String> = spec
2072            .get("args")
2073            .and_then(|v| v.as_array())
2074            .map(|a| {
2075                a.iter()
2076                    .filter_map(|x| x.as_str().map(|s| s.to_string()))
2077                    .collect()
2078            })
2079            .unwrap_or_default();
2080        let use_stdin = spec
2081            .get("use_stdin")
2082            .and_then(|v| v.as_bool())
2083            .unwrap_or(true);
2084        let stream_mode = match spec.get("stream_mode").and_then(|v| v.as_str()) {
2085            Some("ndjson_lines") => Some(StreamMode::NdjsonLines),
2086            Some("sse_events") => Some(StreamMode::SseEvents),
2087            Some("length_prefixed") => Some(StreamMode::LengthPrefixed),
2088            Some(other) => return Err(invalid(format!("unknown stream_mode: {other}"))),
2089            None => None,
2090        };
2091
2092        let mut sp = ProcessSpawner {
2093            program,
2094            args,
2095            use_stdin,
2096            stream_mode,
2097            embed: None,
2098        };
2099        if let Some(mode) = sp.stream_mode.clone() {
2100            sp = sp.stream_mode(mode);
2101        }
2102        Ok(Arc::new(sp))
2103    }
2104}
2105
2106/// Factory for `AgentKind::Lua`. At `build` time it inspects the
2107/// `AgentDef.spec` and returns an [`InProcSpawner`] with the Lua-eval
2108/// `WorkerFn` registered under `agent_name` — one `InProcSpawner`
2109/// instance per agent.
2110///
2111/// Naming convention: `<WorkerIMPL><AdapterType>SpawnerFactory` (Lua
2112/// worker on InProcess adapter). One half of the old
2113/// `InProcSpawnerFactory`, split into Lua and RustFn variants.
2114///
2115/// Spec shape (choose one; `source` wins when both are present):
2116///
2117/// ```jsonc
2118/// // (a) Registry lookup — Lua source id pre-registered with the
2119/// //     factory via `register_lua` (used by the enhance flow's built-in
2120/// //     workers). Requires the factory to know the id at construction
2121/// //     time.
2122/// { "fn_id": "patch-spawner" }
2123///
2124/// // (b) Inline source — a Lua chunk carried by the Blueprint itself,
2125/// //     wrapped on the fly at `build` time. Combined with the loader's
2126/// //     `$file` ref expansion (`"source": {"$file": "gates/foo.lua"}`)
2127/// //     this lets a BP ship deterministic Lua gates without any
2128/// //     pre-registration. `label` is optional and defaults to
2129/// //     `"<agent_name>.lua"` for error messages.
2130/// { "source": "return { value = 42, ok = true }",
2131///   "label": "psim-gate.lua" }
2132/// ```
2133///
2134/// Host bridges registered on the factory (see [`Self::with_bridge`])
2135/// apply to both spec shapes.
2136pub struct LuaInProcessSpawnerFactory {
2137    registry: HashMap<String, WorkerFn>,
2138    bridges: HashMap<String, HostBridge>,
2139}
2140
2141/// Rust-side bridge function callable from Lua.
2142///
2143/// Inputs and outputs are both `serde_json::Value` (i.e. JSON). Lua
2144/// invokes it as `host.<name>(arg_table)`. If the implementation needs
2145/// to call async Rust, the caller does the sync-ification (typically
2146/// `tokio::runtime::Handle::current().block_on(...)`).
2147///
2148/// Design intent: keep Lua scripts focused on flow control and `ctx`
2149/// walking, while the heavy lifting (LLM calls, RFC 6902 apply,
2150/// verifiers, and so on) stays on the Rust side. Going "pure Lua" —
2151/// removing the bridge — is a carry.
2152#[derive(Clone)]
2153pub struct HostBridge(
2154    Arc<dyn Fn(serde_json::Value) -> Result<serde_json::Value, String> + Send + Sync>,
2155);
2156
2157impl HostBridge {
2158    /// Wrap a Rust closure as a bridge callable from Lua.
2159    pub fn new<F>(f: F) -> Self
2160    where
2161        F: Fn(serde_json::Value) -> Result<serde_json::Value, String> + Send + Sync + 'static,
2162    {
2163        Self(Arc::new(f))
2164    }
2165
2166    /// Invoke the bridge directly — a thin trampoline over the inner
2167    /// `Fn`. The production path goes through the Lua runtime, but this
2168    /// stays `pub` so unit tests can exercise the primitive directly.
2169    pub fn call(&self, arg: serde_json::Value) -> Result<serde_json::Value, String> {
2170        (self.0)(arg)
2171    }
2172}
2173
2174/// Carrier type for Lua script sources. Paths are not required — a
2175/// source string plus an identifying label is all it holds.
2176///
2177/// Callers bring in the source (via `include_str!` or similar) and
2178/// register it with the factory through
2179/// [`LuaInProcessSpawnerFactory::register_lua`].
2180#[derive(Clone)]
2181pub struct LuaScriptSource {
2182    /// The Lua chunk source.
2183    pub source: String,
2184    /// Label used in error messages — typically the script's logical id
2185    /// (for example `"patch_spawner.lua"`).
2186    pub label: String,
2187}
2188
2189impl LuaScriptSource {
2190    /// Wrap a Lua chunk source and its error-message label.
2191    pub fn new(source: impl Into<String>, label: impl Into<String>) -> Self {
2192        Self {
2193            source: source.into(),
2194            label: label.into(),
2195        }
2196    }
2197}
2198
2199impl LuaInProcessSpawnerFactory {
2200    /// Start with no registered scripts and no host bridges.
2201    pub fn new() -> Self {
2202        Self {
2203            registry: HashMap::new(),
2204            bridges: HashMap::new(),
2205        }
2206    }
2207
2208    /// Register a host bridge. Subsequent `register_lua` calls snapshot
2209    /// the current bridge set.
2210    ///
2211    /// Ordering rule: register bridges first, then call `register_lua`;
2212    /// bridges added after `register_lua` will not be visible to that
2213    /// script.
2214    pub fn with_bridge(mut self, name: impl Into<String>, bridge: HostBridge) -> Self {
2215        self.bridges.insert(name.into(), bridge);
2216        self
2217    }
2218
2219    /// Register a **Lua-eval Worker** under `fn_id`.
2220    ///
2221    /// Each dispatch spins up a fresh `mlua::Lua` VM, injects globals
2222    /// (`_PROMPT` / `_AGENT` / `_TASK_ID` / `_ATTEMPT` / `_CTX` — the last
2223    /// is `_PROMPT` parsed as JSON, or `nil` if that fails), evaluates
2224    /// the script, and marshals the returned table into a `WorkerResult`.
2225    ///
2226    /// Marshalling rules for the return value:
2227    /// - `{ value = ..., ok = bool }` → `WorkerResult.value` /
2228    ///   `WorkerResult.ok` verbatim.
2229    /// - Anything else → `value = <returned value>`, `ok = true`.
2230    ///
2231    /// Execution runs on `tokio::task::spawn_blocking` because `mlua::Lua`
2232    /// is `!Send` and needs to stay away from the tokio async context.
2233    /// Host bridges (the Lua-to-Rust callback path) previously registered
2234    /// with [`Self::with_bridge`] are snapshotted at call time and
2235    /// injected into every dispatch inside `run_lua_worker`.
2236    pub fn register_lua(mut self, fn_id: impl Into<String>, source: LuaScriptSource) -> Self {
2237        let source = Arc::new(source);
2238        let bridges = Arc::new(self.bridges.clone());
2239        let wrapped: WorkerFn = Arc::new(move |inv| {
2240            let source = source.clone();
2241            let bridges = bridges.clone();
2242            Box::pin(run_lua_worker(source, bridges, inv))
2243        });
2244        self.registry.insert(fn_id.into(), wrapped);
2245        self
2246    }
2247}
2248
2249/// Body of a single Lua-eval invocation (called from `register_lua`).
2250async fn run_lua_worker(
2251    source: Arc<LuaScriptSource>,
2252    bridges: Arc<HashMap<String, HostBridge>>,
2253    inv: crate::worker::adapter::WorkerInvocation,
2254) -> Result<crate::worker::adapter::WorkerResult, crate::worker::adapter::WorkerError> {
2255    use crate::worker::adapter::WorkerError;
2256    use mlua::LuaSerdeExt;
2257
2258    let label = source.label.clone();
2259    let outcome =
2260        tokio::task::spawn_blocking(move || -> Result<(serde_json::Value, bool), String> {
2261            let lua = mlua::Lua::new();
2262            let g = lua.globals();
2263
2264            // 1. Base globals.
2265            g.set("_PROMPT", inv.prompt.clone())
2266                .map_err(|e| format!("set _PROMPT: {e}"))?;
2267            g.set("_AGENT", inv.agent.clone())
2268                .map_err(|e| format!("set _AGENT: {e}"))?;
2269            g.set("_TASK_ID", inv.task_id.to_string())
2270                .map_err(|e| format!("set _TASK_ID: {e}"))?;
2271            g.set("_ATTEMPT", inv.attempt as i64)
2272                .map_err(|e| format!("set _ATTEMPT: {e}"))?;
2273
2274            // 1b. GH #86: the task-context tier, off the same
2275            //     `WorkerInvocation.context` seam the AgentBlock backend
2276            //     reads, rendered through the same shared mapping
2277            //     (`context_globals`) so a Lua gate sees identical globals
2278            //     on either in-process backend and stays portable between
2279            //     them. An absent field contributes no entry, so the
2280            //     global is simply nil — the "insert nothing when absent"
2281            //     contract the rest of this axis follows.
2282            for (name, value) in
2283                crate::worker::agent_block::runtime::context_globals(inv.context.as_ref())
2284            {
2285                let lua_val = lua
2286                    .to_value(&value)
2287                    .map_err(|e| format!("{name} to_value: {e}"))?;
2288                g.set(name.as_str(), lua_val)
2289                    .map_err(|e| format!("set {name}: {e}"))?;
2290            }
2291
2292            // 2. _CTX = JSON parse(_PROMPT); nil on parse failure (co-exists with the plain-string prompt path).
2293            if let Ok(json_val) = serde_json::from_str::<serde_json::Value>(&inv.prompt) {
2294                let lua_val = lua
2295                    .to_value(&json_val)
2296                    .map_err(|e| format!("_CTX to_value: {e}"))?;
2297                g.set("_CTX", lua_val)
2298                    .map_err(|e| format!("set _CTX: {e}"))?;
2299            }
2300
2301            // 3. Inject the host bridge (Lua can call `host.<name>(arg)`).
2302            if !bridges.is_empty() {
2303                let host = lua
2304                    .create_table()
2305                    .map_err(|e| format!("create host table: {e}"))?;
2306                for (name, bridge) in bridges.iter() {
2307                    let bridge = bridge.clone();
2308                    let bname = name.clone();
2309                    let f = lua
2310                        .create_function(move |lua, arg: mlua::Value| {
2311                            let json_arg: serde_json::Value = lua.from_value(arg).map_err(|e| {
2312                                mlua::Error::external(format!("bridge {bname} arg → json: {e}"))
2313                            })?;
2314                            let result_json =
2315                                bridge.call(json_arg).map_err(mlua::Error::external)?;
2316                            lua.to_value(&result_json).map_err(|e| {
2317                                mlua::Error::external(format!("bridge {bname} ret → lua: {e}"))
2318                            })
2319                        })
2320                        .map_err(|e| format!("create_function {name}: {e}"))?;
2321                    host.set(name.as_str(), f)
2322                        .map_err(|e| format!("host.{name} set: {e}"))?;
2323                }
2324                g.set("host", host).map_err(|e| format!("set host: {e}"))?;
2325            }
2326
2327            // 4. eval
2328            let result: mlua::Value = lua
2329                .load(&source.source)
2330                .set_name(&source.label)
2331                .eval()
2332                .map_err(|e| format!("lua eval [{}]: {e}", source.label))?;
2333
2334            // 5. Marshal: shape `{ value=..., ok=true }` or raw value.
2335            let json_result: serde_json::Value = lua
2336                .from_value(result)
2337                .map_err(|e| format!("lua → json [{}]: {e}", source.label))?;
2338
2339            let (value, ok) = match &json_result {
2340                serde_json::Value::Object(map)
2341                    if map.contains_key("value") || map.contains_key("ok") =>
2342                {
2343                    let ok = map.get("ok").and_then(|v| v.as_bool()).unwrap_or(true);
2344                    let value = map.get("value").cloned().unwrap_or(json_result.clone());
2345                    (value, ok)
2346                }
2347                _ => (json_result, true),
2348            };
2349            Ok((value, ok))
2350        })
2351        .await
2352        .map_err(|e| WorkerError::Failed(format!("spawn_blocking join [{label}]: {e}")))?
2353        .map_err(WorkerError::Failed)?;
2354
2355    Ok(crate::worker::adapter::WorkerResult {
2356        value: outcome.0,
2357        ok: outcome.1,
2358        stats: None,
2359    }
2360    .ensure_worker_kind("lua"))
2361}
2362
2363impl Default for LuaInProcessSpawnerFactory {
2364    fn default() -> Self {
2365        Self::new()
2366    }
2367}
2368
2369impl SpawnerFactoryKind for LuaInProcessSpawnerFactory {
2370    const KIND: AgentKind = AgentKind::Lua;
2371    type Worker = LuaWorker;
2372}
2373
2374impl SpawnerFactory for LuaInProcessSpawnerFactory {
2375    fn build(
2376        &self,
2377        agent_def: &AgentDef,
2378        _hint: Option<&Value>,
2379    ) -> Result<Arc<dyn SpawnerAdapter>, CompileError> {
2380        // Inline `spec.source` (a Lua chunk carried by the BP itself) takes
2381        // precedence over `spec.fn_id`. This is the path a BP author uses to
2382        // ship a deterministic Lua gate without pre-registering it with the
2383        // factory — the plumbing (`run_lua_worker` / `LuaScriptSource`) is
2384        // the same, only the entry point differs.
2385        if let Some(source) = agent_def.spec.get("source").and_then(|v| v.as_str()) {
2386            let label = agent_def
2387                .spec
2388                .get("label")
2389                .and_then(|v| v.as_str())
2390                .map(str::to_string)
2391                .unwrap_or_else(|| format!("{}.lua", agent_def.name));
2392            let script = Arc::new(LuaScriptSource::new(source.to_string(), label));
2393            let bridges = Arc::new(self.bridges.clone());
2394            let wrapped: WorkerFn = Arc::new(move |inv| {
2395                let source = script.clone();
2396                let bridges = bridges.clone();
2397                Box::pin(run_lua_worker(source, bridges, inv))
2398            });
2399            let mut sp: InProcSpawner<LuaWorker> = InProcSpawner::<LuaWorker>::typed();
2400            sp.registry.insert(agent_def.name.to_string(), wrapped);
2401            return Ok(Arc::new(sp));
2402        }
2403        build_inproc_from_registry::<LuaWorker>(&self.registry, agent_def, "lua")
2404    }
2405}
2406
2407/// Factory for `AgentKind::RustFn`. At `build` time it looks the `fn_id`
2408/// up in its internal registry and returns an [`InProcSpawner`] with the
2409/// Rust closure `WorkerFn` registered under `agent_name`.
2410///
2411/// Naming convention: `<WorkerIMPL><AdapterType>SpawnerFactory` (RustFn
2412/// worker on InProcess adapter). Sibling to
2413/// [`LuaInProcessSpawnerFactory`] — the Lua-worker half of the same
2414/// split.
2415///
2416/// Spec shape:
2417/// ```jsonc
2418/// { "fn_id": "echo" }     // Rust closure id pre-registered with the factory
2419/// ```
2420pub struct RustFnInProcessSpawnerFactory {
2421    registry: HashMap<String, WorkerFn>,
2422}
2423
2424impl RustFnInProcessSpawnerFactory {
2425    /// Start with no registered closures.
2426    pub fn new() -> Self {
2427        Self {
2428            registry: HashMap::new(),
2429        }
2430    }
2431
2432    /// Register a Rust closure `WorkerFn` under `fn_id`, wrapping it so
2433    /// it matches the `WorkerFn` signature (boxed, pinned future).
2434    pub fn register_fn<F, Fut>(mut self, fn_id: impl Into<String>, f: F) -> Self
2435    where
2436        F: Fn(crate::worker::adapter::WorkerInvocation) -> Fut + Send + Sync + 'static,
2437        Fut: std::future::Future<
2438                Output = Result<
2439                    crate::worker::adapter::WorkerResult,
2440                    crate::worker::adapter::WorkerError,
2441                >,
2442            > + Send
2443            + 'static,
2444    {
2445        let f = Arc::new(f);
2446        let wrapped: WorkerFn = Arc::new(move |inv| {
2447            let f = f.clone();
2448            Box::pin(f(inv))
2449        });
2450        self.registry.insert(fn_id.into(), wrapped);
2451        self
2452    }
2453}
2454
2455impl Default for RustFnInProcessSpawnerFactory {
2456    fn default() -> Self {
2457        Self::new()
2458    }
2459}
2460
2461impl SpawnerFactoryKind for RustFnInProcessSpawnerFactory {
2462    const KIND: AgentKind = AgentKind::RustFn;
2463    type Worker = RustFnWorker;
2464}
2465
2466impl SpawnerFactory for RustFnInProcessSpawnerFactory {
2467    fn build(
2468        &self,
2469        agent_def: &AgentDef,
2470        _hint: Option<&Value>,
2471    ) -> Result<Arc<dyn SpawnerAdapter>, CompileError> {
2472        build_inproc_from_registry::<RustFnWorker>(&self.registry, agent_def, "rust_fn")
2473    }
2474}
2475
2476/// Shared build helper used by both the Lua and the RustFn factories —
2477/// look `spec.fn_id` up in the registry and return an `InProcSpawner`.
2478/// The generic type parameter `W` fixes the per-kind Worker concrete
2479/// type at the type level (the build-site half of the trait's
2480/// associated-type binding across the four-layer cascade).
2481fn build_inproc_from_registry<W>(
2482    registry: &HashMap<String, WorkerFn>,
2483    agent_def: &AgentDef,
2484    kind_label: &str,
2485) -> Result<Arc<dyn SpawnerAdapter>, CompileError>
2486where
2487    W: crate::worker::Worker + From<crate::worker::WorkerJoinHandler> + Send + Sync + 'static,
2488{
2489    let agent_name = &agent_def.name;
2490    let spec = &agent_def.spec;
2491    let invalid = |msg: String| CompileError::InvalidSpec {
2492        name: agent_name.to_string(),
2493        msg,
2494    };
2495    let fn_id = spec
2496        .get("fn_id")
2497        .and_then(|v| v.as_str())
2498        .ok_or_else(|| invalid(format!("{kind_label} spec: 'fn_id' (string) required")))?;
2499    let f = registry
2500        .get(fn_id)
2501        .cloned()
2502        .ok_or_else(|| invalid(format!("fn_id '{fn_id}' not registered in factory")))?;
2503    let mut sp: InProcSpawner<W> = InProcSpawner::<W>::typed();
2504    // Register under `agent_name` (the flow's `Step.ref`). Both
2505    // `CompiledAgentTable` and the `InProcSpawner` look the function up
2506    // by name, so the same key is needed at both layers.
2507    sp.registry.insert(agent_name.to_string(), f);
2508    Ok(Arc::new(sp))
2509}
2510
2511/// Concrete Worker type for the Lua kind — a handle to a Lua-eval task
2512/// inside an mlua VM. Embeds a `WorkerJoinHandler`. Reserved as the home
2513/// for future Lua-specific extensions (an mlua VM cancellation
2514/// mechanism, Lua-side error type retention, and so on).
2515pub struct LuaWorker {
2516    /// The join handle / cancellation token for the underlying task.
2517    pub handler: crate::worker::WorkerJoinHandler,
2518}
2519
2520impl From<crate::worker::WorkerJoinHandler> for LuaWorker {
2521    fn from(handler: crate::worker::WorkerJoinHandler) -> Self {
2522        Self { handler }
2523    }
2524}
2525
2526#[async_trait::async_trait]
2527impl crate::worker::Worker for LuaWorker {
2528    fn id(&self) -> &crate::types::WorkerId {
2529        &self.handler.worker_id
2530    }
2531    fn cancel_token(&self) -> tokio_util::sync::CancellationToken {
2532        self.handler.cancel.clone()
2533    }
2534    async fn join(self: Box<Self>) -> Result<(), crate::worker::adapter::WorkerError> {
2535        self.handler.await_completion().await
2536    }
2537}
2538
2539/// Concrete Worker type for the RustFn kind — a handle to a task that
2540/// directly calls a Rust closure. Embeds a `WorkerJoinHandler`. Being a
2541/// pure function, there is minimal kind-specific extension surface here;
2542/// the primary purpose is to nail down the type binding.
2543pub struct RustFnWorker {
2544    /// The join handle / cancellation token for the underlying task.
2545    pub handler: crate::worker::WorkerJoinHandler,
2546}
2547
2548impl From<crate::worker::WorkerJoinHandler> for RustFnWorker {
2549    fn from(handler: crate::worker::WorkerJoinHandler) -> Self {
2550        Self { handler }
2551    }
2552}
2553
2554#[async_trait::async_trait]
2555impl crate::worker::Worker for RustFnWorker {
2556    fn id(&self) -> &crate::types::WorkerId {
2557        &self.handler.worker_id
2558    }
2559    fn cancel_token(&self) -> tokio_util::sync::CancellationToken {
2560        self.handler.cancel.clone()
2561    }
2562    async fn join(self: Box<Self>) -> Result<(), crate::worker::adapter::WorkerError> {
2563        self.handler.await_completion().await
2564    }
2565}
2566
2567/// Factory for `AgentKind::Operator`. Looks up the `Arc<dyn Operator>`
2568/// pre-registered under `spec.operator_ref` and wraps it in an
2569/// `OperatorSpawner`. Also resolves `AgentDef.profile.worker_binding` into
2570/// a `WorkerBinding` at compile time and fails loud (`CompileError::InvalidSpec`)
2571/// when the resolved operator's `Operator::requires_worker_binding` is `true`
2572/// and no binding was declared.
2573///
2574/// Spec shape:
2575/// ```jsonc
2576/// { "operator_ref": "main_ai" }     // Operator id pre-registered with the factory
2577/// ```
2578///
2579/// # The only axis that reaches an Operator
2580///
2581/// This factory (`OperatorSpawnerFactory` → `OperatorSpawner`) is the
2582/// **AgentSpec axis**: a `kind = Operator` `AgentDef` names its seat
2583/// through `spec.operator_ref`, and at `compile()` time an
2584/// `Arc<dyn Operator>` for that seat is placed in `routes[agent_name]`.
2585/// Because the `agent.md` loader (`agent_md_loader`) defaults `kind` to
2586/// `Operator`, agents that flow in through external agent.md files land
2587/// here.
2588///
2589/// There was a second axis until recently: `OperatorDelegateMiddleware`,
2590/// opted into with `spawner_hints.layers = ["operator_delegate"]`, which
2591/// ignored `ctx.agent` and handed every spawn in the session to one
2592/// backend named at launch. When both were effective it sat at the outer
2593/// end of the stack and bypassed `inner.spawn` entirely, leaving this
2594/// factory's routes entry inert — so the two axes needed an exclusivity
2595/// story, and an author needed to know which one a given Blueprint was
2596/// actually running on.
2597///
2598/// That axis was removed (it resolved its destination from the launch
2599/// record rather than the Run's seat, so a handover could not move it,
2600/// and it had no per-agent spawner with which to render an agent's
2601/// `system_prompt`). Declaring its key is now a
2602/// [`CompileError::RemovedSpawnerHint`]. One axis remains, so there is no
2603/// exclusivity rule left to get wrong: if a dispatch reaches an
2604/// `Operator`, it reached it through the agent's declared seat.
2605///
2606/// # Who answers `spec.operator_ref` — resolver first, registry second
2607///
2608/// `spec.operator_ref` names a Blueprint-declared **seat**
2609/// (`Blueprint.operators[]`), and a seat's holder is per-Run, mutable
2610/// state. Two ways to answer it:
2611///
2612/// - **A [`OperatorSlotResolver`] installed via
2613///   [`Self::set_slot_resolver`]** — the host hands back an indirection
2614///   that resolves the seat's *current* holder on every dispatch
2615///   (`mlua-swarm-server`'s `AssigneeRouter`). What this factory bakes into
2616///   `routes[agent_name]` is then "the seat", not "the session that held it
2617///   when the Blueprint compiled" — model §4.3 **A10**. This is the wiring
2618///   `mse serve` uses.
2619/// - **This factory's own `id → Arc<dyn Operator>` map** (no resolver
2620///   installed) — the direct binding used by hosts with no Run store to
2621///   resolve holders against: `mse bp doctor`'s lint stubs, in-process
2622///   embeddings, tests.
2623///
2624/// The two never mix within one build: an installed resolver that cannot
2625/// serve a seat fails the compile rather than falling through to the map,
2626/// because falling through is exactly how a dispatch ends up at a backend
2627/// the caller never named.
2628///
2629/// Interior mutability is provided by `Arc<RwLock>`s. Even after the
2630/// factory has been stored as `Arc<dyn SpawnerFactory>` in
2631/// `SpawnerRegistry`, a caller holding an `Arc` clone can still add
2632/// Operator backends dynamically via `register_operator(&self, id, op)` or
2633/// install the resolver. `build()` performs a `read()` lookup each time.
2634pub struct OperatorSpawnerFactory {
2635    operators: Arc<std::sync::RwLock<HashMap<String, Arc<dyn Operator>>>>,
2636    /// Installed by the host at wiring time; see the struct doc. `None`
2637    /// keeps the historical registry-lookup behaviour.
2638    slot_resolver: Arc<std::sync::RwLock<Option<Arc<dyn OperatorSlotResolver>>>>,
2639}
2640
2641impl OperatorSpawnerFactory {
2642    /// Start with no registered Operator backends and no slot resolver.
2643    pub fn new() -> Self {
2644        Self {
2645            operators: Arc::new(std::sync::RwLock::new(HashMap::new())),
2646            slot_resolver: Arc::new(std::sync::RwLock::new(None)),
2647        }
2648    }
2649
2650    /// Install the [`OperatorSlotResolver`] every `kind = Operator` agent's
2651    /// `spec.operator_ref` is answered through from now on (see the struct
2652    /// doc). Installing replaces any previous resolver.
2653    ///
2654    /// Takes `&self` on purpose: the host builds its factory before it has
2655    /// a `RunStore` to resolve holders against (the router builder resolves
2656    /// the store), and the same `Arc` is already inside a `SpawnerRegistry`
2657    /// by then.
2658    pub fn set_slot_resolver(&self, resolver: Arc<dyn OperatorSlotResolver>) -> &Self {
2659        *self
2660            .slot_resolver
2661            .write()
2662            .expect("OperatorSpawnerFactory.slot_resolver RwLock poisoned") = Some(resolver);
2663        self
2664    }
2665
2666    /// The `Arc<dyn Operator>` a `kind = Operator` agent declaring
2667    /// `operator_ref = slot` dispatches through — the single lookup
2668    /// [`SpawnerFactory::build`] performs, exposed so a host can assert
2669    /// what its wiring resolves to without standing up a compile.
2670    ///
2671    /// `agent` only shapes the error message (`CompileError::InvalidSpec`
2672    /// is keyed by agent name).
2673    pub fn resolve_operator(
2674        &self,
2675        slot: &str,
2676        agent: &str,
2677    ) -> Result<Arc<dyn Operator>, CompileError> {
2678        let invalid = |msg: String| CompileError::InvalidSpec {
2679            name: agent.to_string(),
2680            msg,
2681        };
2682        let resolver = self
2683            .slot_resolver
2684            .read()
2685            .expect("OperatorSpawnerFactory.slot_resolver RwLock poisoned")
2686            .clone();
2687        if let Some(resolver) = resolver {
2688            return resolver.resolve(slot).ok_or_else(|| {
2689                invalid(format!(
2690                    "operator_ref '{slot}': the installed OperatorSlotResolver serves no such \
2691                     Operator seat. The seat is declared by Blueprint.operators[]; nothing is \
2692                     resolved from the factory's own registry here, because falling back to it \
2693                     would dispatch this agent to a backend the seat does not name."
2694                ))
2695            });
2696        }
2697        let operators = self
2698            .operators
2699            .read()
2700            .expect("OperatorSpawnerFactory.operators RwLock poisoned");
2701        operators.get(slot).cloned().ok_or_else(|| {
2702            let mut names: Vec<String> = operators.keys().cloned().collect();
2703            names.sort();
2704            let names_list = if names.is_empty() {
2705                "<none>".to_string()
2706            } else {
2707                names.join(", ")
2708            };
2709            invalid(format!(
2710                "operator_ref '{slot}' not registered in factory. \
2711                 Registered sids: [{names_list}]. \
2712                 Hint: call mse_operator_join(roles=[...]) to mint the sid first."
2713            ))
2714        })
2715    }
2716
2717    /// Register an Operator backend dynamically through `&self`.
2718    /// Overwrites are allowed — later wins. Callers can still reach this
2719    /// after the factory has been stored as `Arc<dyn SpawnerFactory>` in
2720    /// `SpawnerRegistry`, as long as they hold an `Arc` clone; interior
2721    /// mutability is provided by the inner `RwLock`.
2722    pub fn register_operator(&self, id: impl Into<String>, op: Arc<dyn Operator>) -> &Self {
2723        self.operators
2724            .write()
2725            .expect("OperatorSpawnerFactory.operators RwLock poisoned")
2726            .insert(id.into(), op);
2727        self
2728    }
2729
2730    /// Dynamically unregister an id (used to clean up when a WebSocket
2731    /// disconnects, for example). A missing id is a no-op.
2732    pub fn unregister_operator(&self, id: &str) -> &Self {
2733        self.operators
2734            .write()
2735            .expect("OperatorSpawnerFactory.operators RwLock poisoned")
2736            .remove(id);
2737        self
2738    }
2739}
2740
2741impl Default for OperatorSpawnerFactory {
2742    fn default() -> Self {
2743        Self::new()
2744    }
2745}
2746
2747impl SpawnerFactoryKind for OperatorSpawnerFactory {
2748    const KIND: AgentKind = AgentKind::Operator;
2749    type Worker = crate::operator::OperatorWorker;
2750}
2751
2752impl SpawnerFactory for OperatorSpawnerFactory {
2753    /// No build hint is read here: an Operator agent's whole input is its
2754    /// declared seat (`spec.operator_ref`) plus its profile. The hint slot
2755    /// used to carry a launch-scoped session pin, which was the compile
2756    /// baking a destination — see the struct doc.
2757    fn build(
2758        &self,
2759        agent_def: &AgentDef,
2760        _hint: Option<&Value>,
2761    ) -> Result<Arc<dyn SpawnerAdapter>, CompileError> {
2762        let agent_name = &agent_def.name;
2763        let spec = &agent_def.spec;
2764        // Bake AgentDef.profile.system_prompt into the OperatorSpawner at compile time.
2765        // `Some` → adopted first at spawn time; `None` → falls back to fetch_prompt (initial_directive).
2766        // Fallback path. Sibling: AgentBlockInProcessSpawnerFactory
2767        // (agent_block/runtime.rs) does the same compile-time bake by stuffing
2768        // the profile into BlockConfig.context.
2769        let system_prompt = agent_def.profile.as_ref().map(|p| p.system_prompt.clone());
2770        let invalid = |msg: String| CompileError::InvalidSpec {
2771            name: agent_name.to_string(),
2772            msg,
2773        };
2774        let op_ref = spec
2775            .get("operator_ref")
2776            .and_then(|v| v.as_str())
2777            .ok_or_else(|| invalid("operator spec: 'operator_ref' (string) required".into()))?;
2778        // The seat, not the holder: with a resolver installed this hands
2779        // back the per-dispatch indirection, and without one it is the
2780        // historical direct registry binding. See the struct doc.
2781        let op = self.resolve_operator(op_ref, agent_name)?;
2782
2783        // Resolve the Blueprint-baked worker binding from
2784        // `AgentDef.profile.worker_binding` — the SoT for the
2785        // declaration↔executor binding (see `WorkerBinding` doc). Fail
2786        // loud at compile time when the operator backend requires one
2787        // and the Blueprint didn't declare it; this is a compile-time
2788        // gate, not a runtime guess.
2789        let worker_binding = agent_def
2790            .profile
2791            .as_ref()
2792            .and_then(|p| p.worker_binding.as_ref())
2793            .map(|variant| WorkerBinding {
2794                variant: variant.clone(),
2795                tools: agent_def
2796                    .profile
2797                    .as_ref()
2798                    .map(|p| p.tools.clone())
2799                    .unwrap_or_default(),
2800                // Compile-time path: no immutable BoundAgent snapshot exists
2801                // here (the launch path resolves the digest). Self-check
2802                // inputs are supplied on the launch axis only.
2803                request_digest: None,
2804                requested_model: None,
2805            });
2806        if op.requires_worker_binding() && worker_binding.is_none() {
2807            // Issue #9: the two Blueprint authoring paths (direct JSON
2808            // and `$agent_md` file ref) both land here. Old message
2809            // pointed only at the `.md` frontmatter, which was
2810            // confusing for authors on the JSON-direct path. The prefix
2811            // const keeps this message and the GH #79 Diagnostic
2812            // specialization in lockstep.
2813            return Err(invalid(format!(
2814                "{WORKER_BINDING_REQUIRED_MSG_PREFIX}. \
2815                 Fix by either: \
2816                 (a) if authoring the Blueprint JSON directly, add \
2817                 `agents[N].profile.worker_binding: \"<subagent-type>\"` \
2818                 to the JSON literal; or \
2819                 (b) if using an $agent_md file ref, add \
2820                 `worker_binding: <subagent-type>` to the agent .md frontmatter."
2821            )));
2822        }
2823        Ok(Arc::new(OperatorSpawner::new(
2824            op,
2825            system_prompt,
2826            worker_binding,
2827        )))
2828    }
2829}
2830
2831#[cfg(test)]
2832mod operator_spawner_factory_worker_binding_tests {
2833    use super::*;
2834    use crate::blueprint::AgentProfile;
2835    use crate::core::ctx::Ctx;
2836    use crate::types::CapToken;
2837    use crate::worker::adapter::{WorkerError, WorkerResult};
2838
2839    /// Minimal `Operator` stub whose `requires_worker_binding` is
2840    /// configurable — enough to exercise the compile-time fail-loud gate
2841    /// without standing up a real backend (e.g. `WSOperatorSession`,
2842    /// which lives in a downstream crate).
2843    struct StubOperator {
2844        requires_binding: bool,
2845    }
2846
2847    #[async_trait]
2848    impl Operator for StubOperator {
2849        async fn execute(
2850            &self,
2851            _ctx: &Ctx,
2852            _system: Option<String>,
2853            _prompt: Value,
2854            _worker: Option<WorkerBinding>,
2855            _worker_token: CapToken,
2856        ) -> Result<WorkerResult, WorkerError> {
2857            Ok(WorkerResult {
2858                value: Value::Null,
2859                ok: true,
2860                stats: None,
2861            })
2862        }
2863
2864        fn requires_worker_binding(&self) -> bool {
2865            self.requires_binding
2866        }
2867    }
2868
2869    fn agent_def_with(profile: Option<AgentProfile>) -> AgentDef {
2870        AgentDef {
2871            name: "test-agent".to_string(),
2872            kind: AgentKind::Operator,
2873            spec: serde_json::json!({ "operator_ref": "op1" }),
2874            profile,
2875            meta: None,
2876            runner: None,
2877            runner_ref: None,
2878            verdict: None,
2879            lints: None,
2880        }
2881    }
2882
2883    #[test]
2884    fn build_fails_loud_when_binding_required_but_absent() {
2885        let factory = OperatorSpawnerFactory::new();
2886        factory.register_operator(
2887            "op1",
2888            Arc::new(StubOperator {
2889                requires_binding: true,
2890            }) as Arc<dyn Operator>,
2891        );
2892        let def = agent_def_with(Some(AgentProfile::default()));
2893        match factory.build(&def, None) {
2894            Err(CompileError::InvalidSpec { name, msg }) => {
2895                assert_eq!(name, "test-agent");
2896                assert!(
2897                    msg.contains("worker_binding is required"),
2898                    "unexpected message: {msg}"
2899                );
2900                // Issue #9: the message must be actionable for both
2901                // authoring paths — the JSON-direct hint and the
2902                // $agent_md hint both surface.
2903                assert!(
2904                    msg.contains("agents[N].profile.worker_binding"),
2905                    "message missing JSON-direct hint (issue #9): {msg}"
2906                );
2907                assert!(
2908                    msg.contains("agent .md frontmatter"),
2909                    "message missing $agent_md hint: {msg}"
2910                );
2911            }
2912            Err(other) => panic!("expected InvalidSpec, got: {other:?}"),
2913            Ok(_) => panic!("expected compile-time failure, got Ok"),
2914        }
2915    }
2916
2917    /// GH #79 regression lock: the factory error the compile-time gate
2918    /// emits must keep starting with the shared
2919    /// `WORKER_BINDING_REQUIRED_MSG_PREFIX` — otherwise the
2920    /// `From<&CompileError>` Diagnostic specialization (and `bp_doctor`'s
2921    /// dual-stage `worker-binding-missing` story) silently degrades to
2922    /// the generic `invalid-agent-spec` kind.
2923    #[test]
2924    fn factory_error_message_carries_the_shared_prefix_and_specializes_the_diagnostic() {
2925        let factory = OperatorSpawnerFactory::new();
2926        factory.register_operator(
2927            "op1",
2928            Arc::new(StubOperator {
2929                requires_binding: true,
2930            }) as Arc<dyn Operator>,
2931        );
2932        let def = agent_def_with(Some(AgentProfile::default()));
2933        let err = match factory.build(&def, None) {
2934            Err(err) => err,
2935            Ok(_) => panic!("expected compile-time failure, got Ok"),
2936        };
2937        match &err {
2938            CompileError::InvalidSpec { msg, .. } => {
2939                assert!(
2940                    msg.starts_with(WORKER_BINDING_REQUIRED_MSG_PREFIX),
2941                    "factory message must start with the shared prefix, got: {msg}"
2942                );
2943            }
2944            other => panic!("expected InvalidSpec, got: {other:?}"),
2945        }
2946        let d = mlua_swarm_diag::Diagnostic::from(&err);
2947        assert_eq!(d.kind, "worker-binding-missing");
2948    }
2949
2950    #[test]
2951    fn build_succeeds_when_binding_required_and_present() {
2952        let factory = OperatorSpawnerFactory::new();
2953        factory.register_operator(
2954            "op1",
2955            Arc::new(StubOperator {
2956                requires_binding: true,
2957            }) as Arc<dyn Operator>,
2958        );
2959        let profile = AgentProfile {
2960            worker_binding: Some("code-worker".to_string()),
2961            tools: vec!["Read".to_string(), "Edit".to_string()],
2962            ..Default::default()
2963        };
2964        let def = agent_def_with(Some(profile));
2965        assert!(
2966            factory.build(&def, None).is_ok(),
2967            "expected Ok when worker_binding is declared"
2968        );
2969    }
2970
2971    #[test]
2972    fn build_succeeds_when_binding_not_required_and_absent() {
2973        let factory = OperatorSpawnerFactory::new();
2974        factory.register_operator(
2975            "op1",
2976            Arc::new(StubOperator {
2977                requires_binding: false,
2978            }) as Arc<dyn Operator>,
2979        );
2980        let def = agent_def_with(Some(AgentProfile::default()));
2981        assert!(
2982            factory.build(&def, None).is_ok(),
2983            "backends that don't require a binding must not be gated by its absence"
2984        );
2985    }
2986}
2987
2988// ─── LuaInProcessSpawnerFactory: inline `spec.source` support ─────────────
2989//
2990// Issue `ab3d1145`: BPs served by `mse serve` couldn't declare `kind: lua`
2991// without pre-registering a `fn_id` on the factory. These tests cover the
2992// new inline path — `spec.source = "<lua chunk>"` (optionally with `label`)
2993// wraps a fresh `LuaScriptSource` at `build` time and runs it through the
2994// same `run_lua_worker` plumbing as the registry path.
2995#[cfg(test)]
2996mod lua_inline_source_tests {
2997    use super::*;
2998    use crate::types::{CapToken, Role, StepId};
2999
3000    fn agent(name: &str, spec: Value) -> AgentDef {
3001        AgentDef {
3002            name: name.to_string(),
3003            kind: AgentKind::Lua,
3004            spec,
3005            profile: None,
3006            meta: None,
3007            runner: None,
3008            runner_ref: None,
3009            verdict: None,
3010            lints: None,
3011        }
3012    }
3013
3014    fn test_invocation(prompt: &str) -> crate::worker::adapter::WorkerInvocation {
3015        crate::worker::adapter::WorkerInvocation::new(
3016            CapToken {
3017                agent_id: "a".into(),
3018                role: Role::Worker,
3019                scopes: vec!["*".into()],
3020                issued_at: 0,
3021                expire_at: u64::MAX / 2,
3022                max_uses: None,
3023                nonce: "test-nonce".into(),
3024                sig_hex: "".into(),
3025            },
3026            StepId::parse("ST-test").expect("StepId parse"),
3027            1,
3028            "g",
3029            prompt,
3030        )
3031    }
3032
3033    #[test]
3034    fn build_accepts_inline_source_without_pre_registration() {
3035        let factory = LuaInProcessSpawnerFactory::new();
3036        let def = agent(
3037            "g",
3038            serde_json::json!({ "source": "return { value = 42, ok = true }" }),
3039        );
3040        assert!(
3041            factory.build(&def, None).is_ok(),
3042            "inline spec.source must build without a pre-registered fn_id"
3043        );
3044    }
3045
3046    #[test]
3047    fn build_rejects_when_neither_source_nor_fn_id_is_present() {
3048        let factory = LuaInProcessSpawnerFactory::new();
3049        let def = agent("g", serde_json::json!({}));
3050        match factory.build(&def, None) {
3051            Err(CompileError::InvalidSpec { msg, .. }) => {
3052                assert!(
3053                    msg.contains("fn_id"),
3054                    "empty spec must still surface the fn_id-required message: {msg}"
3055                );
3056            }
3057            Err(other) => panic!("expected InvalidSpec, got a different CompileError: {other}"),
3058            // `SpawnerAdapter` is not Debug, so we can't `unwrap_err()` /
3059            // pattern-print the Ok arm — describe the mismatch directly.
3060            Ok(_) => panic!("expected InvalidSpec, got Ok(SpawnerAdapter)"),
3061        }
3062    }
3063
3064    /// The inline path shares `run_lua_worker` with the registry path, so
3065    /// exercising the marshaller once through it is enough to prove the
3066    /// wrap is faithful.
3067    #[tokio::test]
3068    async fn inline_source_evaluates_and_marshals_result() {
3069        let source =
3070            LuaScriptSource::new("return { value = _PROMPT .. '!', ok = true }", "smoke.lua");
3071        let out = run_lua_worker(
3072            std::sync::Arc::new(source),
3073            std::sync::Arc::new(HashMap::new()),
3074            test_invocation("hello"),
3075        )
3076        .await
3077        .expect("lua worker ok");
3078        assert_eq!(out.value, serde_json::json!("hello!"));
3079        assert!(out.ok);
3080    }
3081
3082    #[tokio::test]
3083    async fn inline_source_can_signal_agent_level_failure() {
3084        // Deterministic gate pattern: return `ok = false` to flip the
3085        // dispatch outcome to `Blocked` (the flow.ir Try catch path).
3086        let source = LuaScriptSource::new("return { value = 'nope', ok = false }", "gate.lua");
3087        let out = run_lua_worker(
3088            std::sync::Arc::new(source),
3089            std::sync::Arc::new(HashMap::new()),
3090            test_invocation("input"),
3091        )
3092        .await
3093        .expect("lua worker ok");
3094        assert_eq!(out.value, serde_json::json!("nope"));
3095        assert!(!out.ok);
3096    }
3097}
3098
3099// ─── GH #21 Phase 2: `Blueprint.metas` / `AgentMeta.meta_ref` / static
3100// `$step_meta.ref` compile-time validation ─────────────────────────────────
3101#[cfg(test)]
3102mod meta_ref_validation_tests {
3103    use super::*;
3104    use crate::blueprint::{AgentMeta, MetaDef};
3105    use crate::worker::adapter::WorkerResult;
3106
3107    fn registry_with_echo() -> SpawnerRegistry {
3108        let factory = RustFnInProcessSpawnerFactory::new().register_fn("echo", |inv| async move {
3109            Ok(WorkerResult {
3110                value: Value::String(inv.prompt),
3111                ok: true,
3112                stats: None,
3113            })
3114        });
3115        let mut reg = SpawnerRegistry::new();
3116        reg.register::<RustFnInProcessSpawnerFactory>(Arc::new(factory));
3117        reg
3118    }
3119
3120    fn rustfn_agent(name: &str) -> AgentDef {
3121        AgentDef {
3122            name: name.to_string(),
3123            kind: AgentKind::RustFn,
3124            spec: serde_json::json!({ "fn_id": "echo" }),
3125            profile: None,
3126            meta: None,
3127            runner: None,
3128            runner_ref: None,
3129            verdict: None,
3130            lints: None,
3131        }
3132    }
3133
3134    fn simple_flow(agent_ref: &str, in_: Expr) -> FlowNode {
3135        FlowNode::Step {
3136            ref_: agent_ref.to_string(),
3137            in_,
3138            out: Expr::Path {
3139                at: "$.output".parse().expect("literal test path: $.output"),
3140            },
3141        }
3142    }
3143
3144    fn minimal_bp(agents: Vec<AgentDef>, metas: Vec<MetaDef>, flow: FlowNode) -> Blueprint {
3145        Blueprint {
3146            schema_version: crate::blueprint::current_schema_version(),
3147            id: "meta-ref-ut".into(),
3148            flow,
3149            agents,
3150            operators: vec![],
3151            metas,
3152            hints: Default::default(),
3153            strategy: Default::default(),
3154            metadata: BlueprintMetadata::default(),
3155            spawner_hints: Default::default(),
3156            default_agent_kind: AgentKind::Operator,
3157            default_operator_kind: None,
3158            default_init_ctx: None,
3159            default_agent_ctx: None,
3160            default_context_policy: None,
3161            projection_placement: None,
3162            audits: vec![],
3163            degradation_policy: None,
3164            runners: vec![],
3165            default_runner: None,
3166            subprocesses: vec![],
3167            check_policy: None,
3168            blueprint_ref_includes: Vec::new(),
3169        }
3170    }
3171
3172    #[test]
3173    fn valid_meta_ref_compiles() {
3174        let mut agent = rustfn_agent("worker");
3175        agent.meta = Some(AgentMeta {
3176            meta_ref: Some("shared".to_string()),
3177            ..Default::default()
3178        });
3179        let bp = minimal_bp(
3180            vec![agent],
3181            vec![MetaDef {
3182                name: "shared".into(),
3183                ctx: serde_json::json!({ "k": "v" }),
3184            }],
3185            simple_flow(
3186                "worker",
3187                Expr::Path {
3188                    at: "$.input".parse().expect("literal test path: $.input"),
3189                },
3190            ),
3191        );
3192        let compiler = Compiler::new(registry_with_echo());
3193        assert!(
3194            compiler.compile(&bp).is_ok(),
3195            "a resolvable AgentMeta.meta_ref must compile"
3196        );
3197    }
3198
3199    #[test]
3200    fn unknown_agent_meta_ref_is_unresolved_meta_ref() {
3201        let mut agent = rustfn_agent("worker");
3202        agent.meta = Some(AgentMeta {
3203            meta_ref: Some("missing".to_string()),
3204            ..Default::default()
3205        });
3206        let bp = minimal_bp(
3207            vec![agent],
3208            vec![],
3209            simple_flow(
3210                "worker",
3211                Expr::Path {
3212                    at: "$.input".parse().expect("literal test path: $.input"),
3213                },
3214            ),
3215        );
3216        let compiler = Compiler::new(registry_with_echo());
3217        match compiler.compile(&bp) {
3218            Err(CompileError::UnresolvedMetaRef {
3219                where_,
3220                meta_ref,
3221                defined,
3222            }) => {
3223                assert!(
3224                    where_.contains("worker"),
3225                    "where_ must name the agent: {where_}"
3226                );
3227                assert_eq!(meta_ref, "missing");
3228                assert!(defined.is_empty());
3229            }
3230            Err(other) => {
3231                panic!("expected UnresolvedMetaRef, got a different CompileError: {other}")
3232            }
3233            Ok(_) => panic!("expected compile-time failure, got Ok"),
3234        }
3235    }
3236
3237    #[test]
3238    fn unknown_static_step_meta_ref_in_lit_is_unresolved_meta_ref() {
3239        let agent = rustfn_agent("worker");
3240        let in_ = Expr::Lit {
3241            value: serde_json::json!({ "$step_meta": { "ref": "missing" }, "$in": "go" }),
3242        };
3243        let bp = minimal_bp(vec![agent], vec![], simple_flow("worker", in_));
3244        let compiler = Compiler::new(registry_with_echo());
3245        match compiler.compile(&bp) {
3246            Err(CompileError::UnresolvedMetaRef {
3247                where_, meta_ref, ..
3248            }) => {
3249                assert!(
3250                    where_.contains("worker"),
3251                    "where_ must name the offending step: {where_}"
3252                );
3253                assert_eq!(meta_ref, "missing");
3254            }
3255            Err(other) => {
3256                panic!("expected UnresolvedMetaRef, got a different CompileError: {other}")
3257            }
3258            Ok(_) => panic!("expected compile-time failure, got Ok"),
3259        }
3260    }
3261
3262    #[test]
3263    fn path_op_input_with_no_static_envelope_compiles_fine() {
3264        let agent = rustfn_agent("worker");
3265        let bp = minimal_bp(
3266            vec![agent],
3267            vec![],
3268            simple_flow(
3269                "worker",
3270                Expr::Path {
3271                    at: "$.input".parse().expect("literal test path: $.input"),
3272                },
3273            ),
3274        );
3275        let compiler = Compiler::new(registry_with_echo());
3276        assert!(
3277            compiler.compile(&bp).is_ok(),
3278            "a non-Lit Step.in must not trigger the best-effort static $step_meta check"
3279        );
3280    }
3281}
3282
3283// ─── GH #34: `Blueprint.audits[].agent` compile-time validation ────────────
3284#[cfg(test)]
3285mod audit_agent_validation_tests {
3286    use super::*;
3287    use crate::worker::adapter::WorkerResult;
3288    use mlua_swarm_schema::{AuditDef, AuditMode};
3289
3290    fn registry_with_echo() -> SpawnerRegistry {
3291        let factory = RustFnInProcessSpawnerFactory::new().register_fn("echo", |inv| async move {
3292            Ok(WorkerResult {
3293                value: Value::String(inv.prompt),
3294                ok: true,
3295                stats: None,
3296            })
3297        });
3298        let mut reg = SpawnerRegistry::new();
3299        reg.register::<RustFnInProcessSpawnerFactory>(Arc::new(factory));
3300        reg
3301    }
3302
3303    fn rustfn_agent(name: &str) -> AgentDef {
3304        AgentDef {
3305            name: name.to_string(),
3306            kind: AgentKind::RustFn,
3307            spec: serde_json::json!({ "fn_id": "echo" }),
3308            profile: None,
3309            meta: None,
3310            runner: None,
3311            runner_ref: None,
3312            verdict: None,
3313            lints: None,
3314        }
3315    }
3316
3317    fn minimal_bp(agents: Vec<AgentDef>, audits: Vec<AuditDef>) -> Blueprint {
3318        Blueprint {
3319            schema_version: crate::blueprint::current_schema_version(),
3320            id: "audit-ref-ut".into(),
3321            flow: FlowNode::Step {
3322                ref_: "worker".to_string(),
3323                in_: Expr::Path {
3324                    at: "$.input".parse().expect("literal test path: $.input"),
3325                },
3326                out: Expr::Path {
3327                    at: "$.output".parse().expect("literal test path: $.output"),
3328                },
3329            },
3330            agents,
3331            operators: vec![],
3332            metas: vec![],
3333            hints: Default::default(),
3334            strategy: Default::default(),
3335            metadata: BlueprintMetadata::default(),
3336            spawner_hints: Default::default(),
3337            default_agent_kind: AgentKind::Operator,
3338            default_operator_kind: None,
3339            default_init_ctx: None,
3340            default_agent_ctx: None,
3341            default_context_policy: None,
3342            projection_placement: None,
3343            audits,
3344            degradation_policy: None,
3345            runners: vec![],
3346            default_runner: None,
3347            subprocesses: vec![],
3348            check_policy: None,
3349            blueprint_ref_includes: Vec::new(),
3350        }
3351    }
3352
3353    #[test]
3354    fn unresolved_audit_agent_is_a_loud_compile_error() {
3355        let bp = minimal_bp(
3356            vec![rustfn_agent("worker")],
3357            vec![AuditDef {
3358                agent: "missing-auditor".to_string(),
3359                steps: None,
3360                mode: AuditMode::default(),
3361            }],
3362        );
3363        let compiler = Compiler::new(registry_with_echo());
3364        match compiler.compile(&bp) {
3365            Err(CompileError::UnresolvedAuditAgent { agent, defined }) => {
3366                assert_eq!(agent, "missing-auditor");
3367                assert_eq!(defined, vec!["worker".to_string()]);
3368            }
3369            Err(other) => {
3370                panic!("expected UnresolvedAuditAgent, got a different CompileError: {other}")
3371            }
3372            Ok(_) => panic!("expected compile-time failure, got Ok"),
3373        }
3374    }
3375
3376    #[test]
3377    fn resolved_audit_agent_compiles_fine() {
3378        let bp = minimal_bp(
3379            vec![rustfn_agent("worker"), rustfn_agent("auditor")],
3380            vec![AuditDef {
3381                agent: "auditor".to_string(),
3382                steps: None,
3383                mode: AuditMode::default(),
3384            }],
3385        );
3386        let compiler = Compiler::new(registry_with_echo());
3387        assert!(
3388            compiler.compile(&bp).is_ok(),
3389            "an audits[].agent that names a declared AgentDef must compile"
3390        );
3391    }
3392}
3393
3394// ─── how `spec.operator_ref` is answered ──────────────────────────────────
3395//
3396// `spec.operator_ref` names a Blueprint-declared Operator seat. These tests
3397// cover both halves of resolving it: the compiler handing the factory
3398// nothing but the author's own hint (no synthesized launch pin — that used
3399// to bake a destination), and the factory answering the seat through an
3400// installed `OperatorSlotResolver` when there is one, or its own registry
3401// when there is not.
3402#[cfg(test)]
3403mod operator_ref_resolution_tests {
3404    use super::*;
3405    use crate::core::ctx::Ctx;
3406    use crate::types::CapToken;
3407    use crate::worker::adapter::{WorkerError, WorkerResult};
3408    use std::sync::Mutex;
3409
3410    /// Shared `(agent, hint)` log the recording factories append to.
3411    type Seen = Arc<Mutex<Vec<(String, Option<Value>)>>>;
3412
3413    /// Records every `(agent, hint)` pair the compiler hands it, so a test
3414    /// can assert on the hint an agent was built with — the pin's whole
3415    /// effect at this layer.
3416    struct RecordingOperatorFactory {
3417        seen: Seen,
3418    }
3419
3420    impl SpawnerFactory for RecordingOperatorFactory {
3421        fn build(
3422            &self,
3423            agent_def: &AgentDef,
3424            hint: Option<&Value>,
3425        ) -> Result<Arc<dyn SpawnerAdapter>, CompileError> {
3426            self.seen
3427                .lock()
3428                .expect("RecordingOperatorFactory.seen poisoned")
3429                .push((agent_def.name.clone(), hint.cloned()));
3430            let mut spawner: InProcSpawner<LuaWorker> = InProcSpawner::<LuaWorker>::typed();
3431            let worker: WorkerFn = Arc::new(|_inv| {
3432                Box::pin(async move {
3433                    Ok(WorkerResult {
3434                        value: Value::Null,
3435                        ok: true,
3436                        stats: None,
3437                    })
3438                })
3439            });
3440            spawner.registry.insert(agent_def.name.clone(), worker);
3441            Ok(Arc::new(spawner))
3442        }
3443    }
3444
3445    impl SpawnerFactoryKind for RecordingOperatorFactory {
3446        const KIND: AgentKind = AgentKind::Operator;
3447        type Worker = crate::operator::OperatorWorker;
3448    }
3449
3450    /// Same recorder on a non-Operator kind, to prove the pin does not
3451    /// leak onto agents it has no business touching.
3452    struct RecordingLuaFactory {
3453        seen: Seen,
3454    }
3455
3456    impl SpawnerFactory for RecordingLuaFactory {
3457        fn build(
3458            &self,
3459            agent_def: &AgentDef,
3460            hint: Option<&Value>,
3461        ) -> Result<Arc<dyn SpawnerAdapter>, CompileError> {
3462            self.seen
3463                .lock()
3464                .expect("RecordingLuaFactory.seen poisoned")
3465                .push((agent_def.name.clone(), hint.cloned()));
3466            let mut spawner: InProcSpawner<LuaWorker> = InProcSpawner::<LuaWorker>::typed();
3467            let worker: WorkerFn = Arc::new(|_inv| {
3468                Box::pin(async move {
3469                    Ok(WorkerResult {
3470                        value: Value::Null,
3471                        ok: true,
3472                        stats: None,
3473                    })
3474                })
3475            });
3476            spawner.registry.insert(agent_def.name.clone(), worker);
3477            Ok(Arc::new(spawner))
3478        }
3479    }
3480
3481    impl SpawnerFactoryKind for RecordingLuaFactory {
3482        const KIND: AgentKind = AgentKind::Lua;
3483        type Worker = LuaWorker;
3484    }
3485
3486    fn recording_compiler() -> (Compiler, Seen, Seen) {
3487        let operator_seen: Seen = Arc::new(Mutex::new(Vec::new()));
3488        let lua_seen: Seen = Arc::new(Mutex::new(Vec::new()));
3489        let mut registry = SpawnerRegistry::new();
3490        registry.register::<RecordingOperatorFactory>(Arc::new(RecordingOperatorFactory {
3491            seen: operator_seen.clone(),
3492        }));
3493        registry.register::<RecordingLuaFactory>(Arc::new(RecordingLuaFactory {
3494            seen: lua_seen.clone(),
3495        }));
3496        (Compiler::new(registry), operator_seen, lua_seen)
3497    }
3498
3499    /// Two agents (one Operator on role `main-ai`, one Lua) plus an
3500    /// author-declared `hints.per_agent` entry on the Operator one, so the
3501    /// merge behaviour is observable.
3502    fn bp_with_operator_and_lua_agents() -> Blueprint {
3503        serde_json::from_value(serde_json::json!({
3504            "schema_version": crate::blueprint::current_schema_version(),
3505            "id": "operator-pin-ut",
3506            "flow": {
3507                "kind": "step",
3508                "ref": "planner",
3509                "in": { "op": "path", "at": "$.input" },
3510                "out": { "op": "path", "at": "$.output" }
3511            },
3512            "agents": [
3513                {
3514                    "name": "planner",
3515                    "kind": "operator",
3516                    "spec": { "operator_ref": "main-ai" }
3517                },
3518                {
3519                    "name": "scorer",
3520                    "kind": "lua",
3521                    "spec": { "source": "return { value = 1, ok = true }" }
3522                }
3523            ],
3524            "operators": [{ "name": "main-ai" }],
3525            "hints": { "per_agent": { "planner": { "authored": "keep-me" } } },
3526            "strategy": { "strict_refs": false }
3527        }))
3528        .expect("test Blueprint literal")
3529    }
3530
3531    fn hint_for(seen: &Seen, agent: &str) -> Option<Value> {
3532        seen.lock()
3533            .expect("seen poisoned")
3534            .iter()
3535            .find(|(name, _)| name == agent)
3536            .map(|(_, hint)| hint.clone())
3537            .expect("agent was never built")
3538    }
3539
3540    /// Regression lock: the compile synthesizes no build hint of its own.
3541    /// The Operator factory sees exactly the authored hint, and an agent
3542    /// with no authored hint still sees `None` — a compile that quietly
3543    /// added a key here is how a launch-scoped destination got baked in.
3544    #[test]
3545    fn the_compile_hands_the_factory_the_authored_hint_untouched() {
3546        let (compiler, operator_seen, lua_seen) = recording_compiler();
3547        let bp = bp_with_operator_and_lua_agents();
3548        let bound = resolve_bound_agents(&bp).expect("resolve bound agents");
3549        compiler.compile_bound(&bp, &bound).expect("compile");
3550
3551        assert_eq!(
3552            hint_for(&operator_seen, "planner"),
3553            Some(serde_json::json!({ "authored": "keep-me" })),
3554            "the compile must hand over the authored hint verbatim"
3555        );
3556        assert_eq!(
3557            hint_for(&lua_seen, "scorer"),
3558            None,
3559            "an agent with no authored hint must still be built with None"
3560        );
3561    }
3562
3563    /// Whatever shape the author declared for a hint is the author's
3564    /// business — nothing in the compile needs to read or extend it.
3565    #[test]
3566    fn a_non_object_authored_hint_is_none_of_the_compilers_business() {
3567        let (compiler, _operator_seen, _lua_seen) = recording_compiler();
3568        let mut bp = bp_with_operator_and_lua_agents();
3569        bp.hints
3570            .per_agent
3571            .insert("planner".to_string(), Value::String("not-an-object".into()));
3572        let bound = resolve_bound_agents(&bp).expect("resolve bound agents");
3573        assert!(
3574            compiler.compile_bound(&bp, &bound).is_ok(),
3575            "the compile must accept whatever hint shape the author declared"
3576        );
3577    }
3578
3579    // ── factory-level resolution ─────────────────────────────────────────
3580
3581    /// Backend stub whose `requires_worker_binding` doubles as an identity
3582    /// marker: the two registrations below disagree on it, so which one the
3583    /// factory picked is visible in `build`'s outcome alone.
3584    struct StubOperator {
3585        requires_binding: bool,
3586    }
3587
3588    #[async_trait]
3589    impl Operator for StubOperator {
3590        async fn execute(
3591            &self,
3592            _ctx: &Ctx,
3593            _system: Option<String>,
3594            _prompt: Value,
3595            _worker: Option<WorkerBinding>,
3596            _worker_token: CapToken,
3597        ) -> Result<WorkerResult, WorkerError> {
3598            Ok(WorkerResult {
3599                value: Value::Null,
3600                ok: true,
3601                stats: None,
3602            })
3603        }
3604
3605        fn requires_worker_binding(&self) -> bool {
3606            self.requires_binding
3607        }
3608    }
3609
3610    fn operator_agent() -> AgentDef {
3611        AgentDef {
3612            name: "planner".to_string(),
3613            kind: AgentKind::Operator,
3614            spec: serde_json::json!({ "operator_ref": "main-ai" }),
3615            profile: None,
3616            meta: None,
3617            runner: None,
3618            runner_ref: None,
3619            verdict: None,
3620            lints: None,
3621        }
3622    }
3623
3624    /// Answers a fixed set of seats, recording which ones it was asked
3625    /// for — a stand-in for the host wiring that hands back a per-dispatch
3626    /// holder lookup.
3627    struct StubResolver {
3628        seats: Vec<&'static str>,
3629        asked: Mutex<Vec<String>>,
3630    }
3631
3632    impl OperatorSlotResolver for StubResolver {
3633        fn resolve(&self, slot: &str) -> Option<Arc<dyn Operator>> {
3634            self.asked
3635                .lock()
3636                .expect("StubResolver.asked poisoned")
3637                .push(slot.to_string());
3638            self.seats.contains(&slot).then(|| {
3639                Arc::new(StubOperator {
3640                    requires_binding: false,
3641                }) as Arc<dyn Operator>
3642            })
3643        }
3644    }
3645
3646    /// An installed resolver answers the declared seat, and the factory's
3647    /// own registry is not consulted at all — the entry sitting under the
3648    /// same name is deliberately one that would reject this agent, so a
3649    /// successful build proves which side answered.
3650    #[test]
3651    fn an_installed_resolver_answers_the_seat_and_the_registry_is_not_consulted() {
3652        let factory = OperatorSpawnerFactory::new();
3653        factory.register_operator(
3654            "main-ai",
3655            Arc::new(StubOperator {
3656                requires_binding: true,
3657            }) as Arc<dyn Operator>,
3658        );
3659        let resolver = Arc::new(StubResolver {
3660            seats: vec!["main-ai"],
3661            asked: Mutex::new(Vec::new()),
3662        });
3663        factory.set_slot_resolver(resolver.clone());
3664
3665        assert!(
3666            factory.build(&operator_agent(), None).is_ok(),
3667            "the installed resolver must answer the seat, not the registry entry \
3668             registered under the same name"
3669        );
3670        assert_eq!(
3671            *resolver.asked.lock().expect("asked"),
3672            vec!["main-ai".to_string()],
3673            "the resolver is asked for the seat the AgentDef declares"
3674        );
3675    }
3676
3677    /// A resolver that serves no such seat fails the build loudly. There is
3678    /// no second chance from the factory's own registry — falling back is
3679    /// how a dispatch reaches a backend the seat never named.
3680    #[test]
3681    fn a_resolver_miss_fails_loud_and_never_falls_back_to_the_registry() {
3682        let factory = OperatorSpawnerFactory::new();
3683        factory.register_operator(
3684            "main-ai",
3685            Arc::new(StubOperator {
3686                requires_binding: false,
3687            }) as Arc<dyn Operator>,
3688        );
3689        factory.set_slot_resolver(Arc::new(StubResolver {
3690            seats: vec!["some-other-seat"],
3691            asked: Mutex::new(Vec::new()),
3692        }));
3693
3694        match factory.build(&operator_agent(), None) {
3695            Err(CompileError::InvalidSpec { name, msg }) => {
3696                assert_eq!(name, "planner");
3697                assert!(
3698                    msg.contains("main-ai"),
3699                    "message must name the seat that went unserved: {msg}"
3700                );
3701                assert!(
3702                    msg.contains("OperatorSlotResolver"),
3703                    "message must say which side refused, so the wiring is the \
3704                     obvious suspect: {msg}"
3705                );
3706            }
3707            Err(other) => panic!("expected InvalidSpec, got a different CompileError: {other}"),
3708            Ok(_) => panic!(
3709                "an unserved seat must fail the compile, not silently resolve the \
3710                 registry entry"
3711            ),
3712        }
3713    }
3714
3715    /// With no resolver installed the factory keeps its direct registry
3716    /// binding — the wiring `mse bp doctor` and in-process embeddings use.
3717    #[test]
3718    fn without_a_resolver_the_registry_answers_with_the_historical_message() {
3719        let factory = OperatorSpawnerFactory::new();
3720        match factory.build(&operator_agent(), None) {
3721            Err(CompileError::InvalidSpec { msg, .. }) => {
3722                assert!(
3723                    msg.contains("operator_ref 'main-ai' not registered in factory"),
3724                    "the registry-side message must stay the historical one: {msg}"
3725                );
3726            }
3727            Err(other) => panic!("expected InvalidSpec, got a different CompileError: {other}"),
3728            Ok(_) => panic!("an unregistered seat must still fail"),
3729        }
3730        factory.register_operator(
3731            "main-ai",
3732            Arc::new(StubOperator {
3733                requires_binding: false,
3734            }) as Arc<dyn Operator>,
3735        );
3736        assert!(
3737            factory.build(&operator_agent(), None).is_ok(),
3738            "a registered backend must still resolve the seat directly"
3739        );
3740    }
3741}
3742
3743// ─── GH #27 (follow-up to #23): `Blueprint.projection_placement` compile-time
3744// validation + `CompiledBlueprint.projection_placement` construction ────────
3745#[cfg(test)]
3746mod projection_placement_compile_tests {
3747    use super::*;
3748    use crate::core::projection_placement::{ProjectionPlacement, RootPreference};
3749    use crate::worker::adapter::WorkerResult;
3750    use mlua_swarm_schema::ProjectionPlacementSpec;
3751
3752    fn registry_with_echo() -> SpawnerRegistry {
3753        let factory = RustFnInProcessSpawnerFactory::new().register_fn("echo", |inv| async move {
3754            Ok(WorkerResult {
3755                value: Value::String(inv.prompt),
3756                ok: true,
3757                stats: None,
3758            })
3759        });
3760        let mut reg = SpawnerRegistry::new();
3761        reg.register::<RustFnInProcessSpawnerFactory>(Arc::new(factory));
3762        reg
3763    }
3764
3765    fn minimal_bp(projection_placement: Option<ProjectionPlacementSpec>) -> Blueprint {
3766        Blueprint {
3767            schema_version: crate::blueprint::current_schema_version(),
3768            id: "projection-placement-ut".into(),
3769            flow: FlowNode::Step {
3770                ref_: "worker".to_string(),
3771                in_: Expr::Path {
3772                    at: "$.input".parse().expect("literal test path: $.input"),
3773                },
3774                out: Expr::Path {
3775                    at: "$.output".parse().expect("literal test path: $.output"),
3776                },
3777            },
3778            agents: vec![AgentDef {
3779                name: "worker".to_string(),
3780                kind: AgentKind::RustFn,
3781                spec: serde_json::json!({ "fn_id": "echo" }),
3782                profile: None,
3783                meta: None,
3784                runner: None,
3785                runner_ref: None,
3786                verdict: None,
3787                lints: None,
3788            }],
3789            operators: vec![],
3790            metas: vec![],
3791            hints: Default::default(),
3792            strategy: Default::default(),
3793            metadata: BlueprintMetadata::default(),
3794            spawner_hints: Default::default(),
3795            default_agent_kind: AgentKind::Operator,
3796            default_operator_kind: None,
3797            default_init_ctx: None,
3798            default_agent_ctx: None,
3799            default_context_policy: None,
3800            projection_placement,
3801            audits: vec![],
3802            degradation_policy: None,
3803            runners: vec![],
3804            default_runner: None,
3805            subprocesses: vec![],
3806            check_policy: None,
3807            blueprint_ref_includes: Vec::new(),
3808        }
3809    }
3810
3811    #[test]
3812    fn undeclared_projection_placement_compiles_to_byte_compat_default() {
3813        let bp = minimal_bp(None);
3814        let compiled = Compiler::new(registry_with_echo())
3815            .compile(&bp)
3816            .expect("undeclared projection_placement compiles");
3817        assert_eq!(
3818            *compiled.projection_placement,
3819            ProjectionPlacement::default()
3820        );
3821    }
3822
3823    #[test]
3824    fn declared_valid_projection_placement_compiles_to_matching_resolver() {
3825        let bp = minimal_bp(Some(ProjectionPlacementSpec {
3826            root: Some("project_root".to_string()),
3827            dir_template: Some("custom/{task_id}/out".to_string()),
3828        }));
3829        let compiled = Compiler::new(registry_with_echo())
3830            .compile(&bp)
3831            .expect("valid projection_placement compiles");
3832        assert_eq!(
3833            compiled.projection_placement.root_preference,
3834            RootPreference::ProjectRoot
3835        );
3836        assert_eq!(
3837            compiled.projection_placement.dir_template,
3838            "custom/{task_id}/out"
3839        );
3840    }
3841
3842    #[test]
3843    fn declared_invalid_dir_template_rejects_compile() {
3844        let bp = minimal_bp(Some(ProjectionPlacementSpec {
3845            root: None,
3846            dir_template: Some("workspace/tasks/ctx".to_string()), // missing {task_id}
3847        }));
3848        match Compiler::new(registry_with_echo()).compile(&bp) {
3849            Err(CompileError::InvalidProjectionPlacement(_)) => {}
3850            Err(other) => {
3851                panic!("expected InvalidProjectionPlacement, got a different CompileError: {other}")
3852            }
3853            Ok(_) => {
3854                panic!("expected compile-time rejection for a missing {{task_id}} placeholder")
3855            }
3856        }
3857    }
3858
3859    #[test]
3860    fn declared_invalid_root_literal_rejects_compile() {
3861        let bp = minimal_bp(Some(ProjectionPlacementSpec {
3862            root: Some("nope".to_string()),
3863            dir_template: None,
3864        }));
3865        match Compiler::new(registry_with_echo()).compile(&bp) {
3866            Err(CompileError::InvalidProjectionPlacement(_)) => {}
3867            Err(other) => {
3868                panic!("expected InvalidProjectionPlacement, got a different CompileError: {other}")
3869            }
3870            Ok(_) => panic!("expected compile-time rejection for an invalid root literal"),
3871        }
3872    }
3873}
3874
3875// ─── GH #50: `Blueprint.agents[].verdict` cond↔output-shape lint ──────────
3876#[cfg(test)]
3877mod verdict_contract_lint_tests {
3878    use super::*;
3879    use crate::worker::adapter::WorkerResult;
3880
3881    fn registry_with_echo() -> SpawnerRegistry {
3882        let factory = RustFnInProcessSpawnerFactory::new().register_fn("echo", |inv| async move {
3883            Ok(WorkerResult {
3884                value: Value::String(inv.prompt),
3885                ok: true,
3886                stats: None,
3887            })
3888        });
3889        let mut reg = SpawnerRegistry::new();
3890        reg.register::<RustFnInProcessSpawnerFactory>(Arc::new(factory));
3891        reg
3892    }
3893
3894    fn gate_agent(verdict: Option<VerdictContract>) -> AgentDef {
3895        AgentDef {
3896            name: "gate".to_string(),
3897            kind: AgentKind::RustFn,
3898            spec: serde_json::json!({ "fn_id": "echo" }),
3899            profile: None,
3900            meta: None,
3901            runner: None,
3902            runner_ref: None,
3903            verdict,
3904            lints: None,
3905        }
3906    }
3907
3908    fn minimal_bp(agent: AgentDef, flow: FlowNode) -> Blueprint {
3909        Blueprint {
3910            schema_version: crate::blueprint::current_schema_version(),
3911            id: "verdict-contract-ut".into(),
3912            flow,
3913            agents: vec![agent],
3914            operators: vec![],
3915            metas: vec![],
3916            hints: Default::default(),
3917            strategy: Default::default(),
3918            metadata: BlueprintMetadata::default(),
3919            spawner_hints: Default::default(),
3920            default_agent_kind: AgentKind::Operator,
3921            default_operator_kind: None,
3922            default_init_ctx: None,
3923            default_agent_ctx: None,
3924            default_context_policy: None,
3925            projection_placement: None,
3926            audits: vec![],
3927            degradation_policy: None,
3928            runners: vec![],
3929            default_runner: None,
3930            subprocesses: vec![],
3931            check_policy: None,
3932            blueprint_ref_includes: Vec::new(),
3933        }
3934    }
3935
3936    fn step(ref_: &str, out_path: &str) -> FlowNode {
3937        FlowNode::Step {
3938            ref_: ref_.to_string(),
3939            in_: Expr::Lit { value: Value::Null },
3940            out: Expr::Path {
3941                at: out_path.parse().expect("literal test path"),
3942            },
3943        }
3944    }
3945
3946    fn noop() -> FlowNode {
3947        FlowNode::Seq { children: vec![] }
3948    }
3949
3950    fn eq_cond(path: &str, lit: &str) -> Expr {
3951        Expr::Eq {
3952            lhs: Box::new(Expr::Path {
3953                at: path.parse().expect("literal test path"),
3954            }),
3955            rhs: Box::new(Expr::Lit {
3956                value: Value::String(lit.to_string()),
3957            }),
3958        }
3959    }
3960
3961    fn branch(cond: Expr, then_: FlowNode, else_: FlowNode) -> FlowNode {
3962        FlowNode::Branch {
3963            cond,
3964            then_: Box::new(then_),
3965            else_: Box::new(else_),
3966        }
3967    }
3968
3969    fn body_contract(values: &[&str]) -> VerdictContract {
3970        VerdictContract {
3971            channel: VerdictChannel::Body,
3972            values: values.iter().map(|v| v.to_string()).collect(),
3973        }
3974    }
3975
3976    fn part_contract(values: &[&str]) -> VerdictContract {
3977        VerdictContract {
3978            channel: VerdictChannel::Part,
3979            values: values.iter().map(|v| v.to_string()).collect(),
3980        }
3981    }
3982
3983    #[test]
3984    fn contract_with_correct_body_channel_and_value_compiles() {
3985        let agent = gate_agent(Some(body_contract(&["PASS", "BLOCKED"])));
3986        let flow = FlowNode::Seq {
3987            children: vec![
3988                step("gate", "$.verdict"),
3989                branch(eq_cond("$.verdict", "BLOCKED"), noop(), noop()),
3990            ],
3991        };
3992        let bp = minimal_bp(agent, flow);
3993        assert!(
3994            Compiler::new(registry_with_echo()).compile(&bp).is_ok(),
3995            "a cond addressing the bare step output must match a channel: \"body\" contract"
3996        );
3997    }
3998
3999    #[test]
4000    fn contract_with_correct_part_channel_and_value_compiles() {
4001        let agent = gate_agent(Some(part_contract(&["PASS", "BLOCKED"])));
4002        let flow = FlowNode::Seq {
4003            children: vec![
4004                step("gate", "$.gate"),
4005                branch(eq_cond("$.gate.parts.verdict", "BLOCKED"), noop(), noop()),
4006            ],
4007        };
4008        let bp = minimal_bp(agent, flow);
4009        assert!(
4010            Compiler::new(registry_with_echo()).compile(&bp).is_ok(),
4011            "a cond addressing '<step>.parts.verdict' must match a channel: \"part\" contract"
4012        );
4013    }
4014
4015    #[test]
4016    fn body_channel_contract_rejects_cond_addressing_parts_verdict() {
4017        // Pattern A declared (channel: "body") but the cond addresses the
4018        // Pattern B shape ('$.gate.parts.verdict') instead of the bare
4019        // step output — GH #50 register-time enforcement point 1.
4020        let agent = gate_agent(Some(body_contract(&["PASS", "BLOCKED"])));
4021        let flow = FlowNode::Seq {
4022            children: vec![
4023                step("gate", "$.gate"),
4024                branch(eq_cond("$.gate.parts.verdict", "BLOCKED"), noop(), noop()),
4025            ],
4026        };
4027        let bp = minimal_bp(agent, flow);
4028        match Compiler::new(registry_with_echo()).compile(&bp) {
4029            Err(CompileError::VerdictChannelMismatch {
4030                where_,
4031                agent,
4032                expected_channel,
4033                actual_shape,
4034            }) => {
4035                assert_eq!(agent, "gate");
4036                assert_eq!(expected_channel, "body");
4037                assert_eq!(actual_shape, "part");
4038                assert!(where_.contains("Branch cond"), "where_: {where_}");
4039            }
4040            Err(other) => {
4041                panic!("expected VerdictChannelMismatch, got a different CompileError: {other}")
4042            }
4043            Ok(_) => panic!("expected compile-time rejection for the wrong channel shape"),
4044        }
4045    }
4046
4047    #[test]
4048    fn part_channel_contract_rejects_cond_addressing_bare_output() {
4049        // Inverse of the previous case: channel: "part" declared, but the
4050        // cond addresses the bare step output.
4051        let agent = gate_agent(Some(part_contract(&["PASS", "BLOCKED"])));
4052        let flow = FlowNode::Seq {
4053            children: vec![
4054                step("gate", "$.verdict"),
4055                branch(eq_cond("$.verdict", "BLOCKED"), noop(), noop()),
4056            ],
4057        };
4058        let bp = minimal_bp(agent, flow);
4059        match Compiler::new(registry_with_echo()).compile(&bp) {
4060            Err(CompileError::VerdictChannelMismatch {
4061                agent,
4062                expected_channel,
4063                actual_shape,
4064                ..
4065            }) => {
4066                assert_eq!(agent, "gate");
4067                assert_eq!(expected_channel, "part");
4068                assert_eq!(actual_shape, "body");
4069            }
4070            Err(other) => {
4071                panic!("expected VerdictChannelMismatch, got a different CompileError: {other}")
4072            }
4073            Ok(_) => panic!("expected compile-time rejection for the wrong channel shape"),
4074        }
4075    }
4076
4077    #[test]
4078    fn contract_rejects_lit_outside_declared_values() {
4079        let agent = gate_agent(Some(body_contract(&["PASS", "BLOCKED"])));
4080        let flow = FlowNode::Seq {
4081            children: vec![
4082                step("gate", "$.verdict"),
4083                branch(eq_cond("$.verdict", "UNKNOWN"), noop(), noop()),
4084            ],
4085        };
4086        let bp = minimal_bp(agent, flow);
4087        match Compiler::new(registry_with_echo()).compile(&bp) {
4088            Err(CompileError::VerdictValueNotInContract {
4089                agent,
4090                value,
4091                values,
4092                ..
4093            }) => {
4094                assert_eq!(agent, "gate");
4095                assert_eq!(value, "UNKNOWN");
4096                assert_eq!(values, vec!["PASS".to_string(), "BLOCKED".to_string()]);
4097            }
4098            Err(other) => {
4099                panic!("expected VerdictValueNotInContract, got a different CompileError: {other}")
4100            }
4101            Ok(_) => panic!("expected compile-time rejection for a Lit outside declared values"),
4102        }
4103    }
4104
4105    #[test]
4106    fn undeclared_agent_referenced_by_cond_compiles_with_warning_only() {
4107        let agent = gate_agent(None);
4108        let flow = FlowNode::Seq {
4109            children: vec![
4110                step("gate", "$.verdict"),
4111                branch(eq_cond("$.verdict", "BLOCKED"), noop(), noop()),
4112            ],
4113        };
4114        let bp = minimal_bp(agent, flow);
4115        assert!(
4116            Compiler::new(registry_with_echo()).compile(&bp).is_ok(),
4117            "an undeclared verdict contract must never reject compile (opt-in, back-compat)"
4118        );
4119    }
4120
4121    #[test]
4122    fn in_expr_with_lit_haystack_members_compiles() {
4123        let agent = gate_agent(Some(body_contract(&["PASS", "BLOCKED"])));
4124        let cond = Expr::In {
4125            needle: Box::new(Expr::Path {
4126                at: "$.verdict".parse().expect("literal test path"),
4127            }),
4128            haystack: Box::new(Expr::Lit {
4129                value: serde_json::json!(["PASS", "BLOCKED"]),
4130            }),
4131        };
4132        let flow = FlowNode::Seq {
4133            children: vec![step("gate", "$.verdict"), branch(cond, noop(), noop())],
4134        };
4135        let bp = minimal_bp(agent, flow);
4136        assert!(
4137            Compiler::new(registry_with_echo()).compile(&bp).is_ok(),
4138            "an `In` haystack whose every Lit is a declared value must compile"
4139        );
4140    }
4141
4142    /// GH #50 follow-up (issue `33bc825b`): opt-in strict mode rejects a
4143    /// Blueprint whose declared `verdict.values` set includes at least one
4144    /// entry that no downstream `Branch`/`Loop` `cond` references. The
4145    /// contract declares `["PASS", "BLOCKED"]` but only "BLOCKED" is
4146    /// referenced by the cond → "PASS" is unhandled → `CompileError::
4147    /// VerdictValueUnhandled` under `strict_verdict_handling: Some(true)`.
4148    #[test]
4149    fn strict_mode_rejects_unhandled_declared_value() {
4150        let agent = gate_agent(Some(body_contract(&["PASS", "BLOCKED"])));
4151        let flow = FlowNode::Seq {
4152            children: vec![
4153                step("gate", "$.verdict"),
4154                branch(eq_cond("$.verdict", "BLOCKED"), noop(), noop()),
4155            ],
4156        };
4157        let mut bp = minimal_bp(agent, flow);
4158        bp.metadata.strict_verdict_handling = Some(true);
4159        match Compiler::new(registry_with_echo()).compile(&bp) {
4160            Err(CompileError::VerdictValueUnhandled {
4161                agent,
4162                value,
4163                declared_values,
4164                step_ref,
4165            }) => {
4166                assert_eq!(agent, "gate");
4167                assert_eq!(value, "PASS");
4168                assert_eq!(
4169                    declared_values,
4170                    vec!["PASS".to_string(), "BLOCKED".to_string()]
4171                );
4172                assert_eq!(step_ref, "gate");
4173            }
4174            Err(other) => {
4175                panic!("expected VerdictValueUnhandled, got a different CompileError: {other}")
4176            }
4177            Ok(_) => panic!(
4178                "expected compile-time rejection for a declared verdict value with no \
4179                 downstream handler under strict_verdict_handling=Some(true)"
4180            ),
4181        }
4182    }
4183
4184    /// GH #50 follow-up (issue `33bc825b`): default mode (i.e.
4185    /// `strict_verdict_handling` absent or `Some(false)`) surfaces
4186    /// unhandled declared values via `tracing::warn!` only — the compile
4187    /// still succeeds. This preserves back-compat with GH #50's original
4188    /// test cases (many of which declare `values = ["PASS", "BLOCKED"]`
4189    /// and cond-reference only one).
4190    #[test]
4191    fn default_mode_permits_unhandled_declared_value() {
4192        let agent = gate_agent(Some(body_contract(&["PASS", "BLOCKED"])));
4193        let flow = FlowNode::Seq {
4194            children: vec![
4195                step("gate", "$.verdict"),
4196                branch(eq_cond("$.verdict", "BLOCKED"), noop(), noop()),
4197            ],
4198        };
4199        let bp = minimal_bp(agent, flow);
4200        // `strict_verdict_handling` left as `None` (default)
4201        assert!(
4202            Compiler::new(registry_with_echo()).compile(&bp).is_ok(),
4203            "default mode must never reject a Blueprint for unhandled declared values \
4204             (opt-in, back-compat with GH #50)"
4205        );
4206    }
4207
4208    /// GH #50 follow-up (issue `33bc825b`): under strict mode, when every
4209    /// declared value is referenced by at least one downstream cond, the
4210    /// compile succeeds. This tests the positive path of the reverse-
4211    /// direction lint.
4212    #[test]
4213    fn strict_mode_accepts_all_declared_values_handled() {
4214        let agent = gate_agent(Some(body_contract(&["PASS", "BLOCKED"])));
4215        // Two branches, each cond referencing one declared value —
4216        // together they cover the full `values` set.
4217        let flow = FlowNode::Seq {
4218            children: vec![
4219                step("gate", "$.verdict"),
4220                branch(eq_cond("$.verdict", "BLOCKED"), noop(), noop()),
4221                branch(eq_cond("$.verdict", "PASS"), noop(), noop()),
4222            ],
4223        };
4224        let mut bp = minimal_bp(agent, flow);
4225        bp.metadata.strict_verdict_handling = Some(true);
4226        assert!(
4227            Compiler::new(registry_with_echo()).compile(&bp).is_ok(),
4228            "strict mode must accept a Blueprint that handles every declared value"
4229        );
4230    }
4231
4232    /// GH #50 follow-up (issue `33bc825b`): under strict mode, an `In`
4233    /// cond whose `Lit` haystack lists every declared value satisfies
4234    /// the handler-coverage check in one go.
4235    #[test]
4236    fn strict_mode_accepts_declared_values_covered_by_in_expr() {
4237        let agent = gate_agent(Some(body_contract(&["PASS", "BLOCKED"])));
4238        let cond = Expr::In {
4239            needle: Box::new(Expr::Path {
4240                at: "$.verdict".parse().expect("literal test path"),
4241            }),
4242            haystack: Box::new(Expr::Lit {
4243                value: serde_json::json!(["PASS", "BLOCKED"]),
4244            }),
4245        };
4246        let flow = FlowNode::Seq {
4247            children: vec![step("gate", "$.verdict"), branch(cond, noop(), noop())],
4248        };
4249        let mut bp = minimal_bp(agent, flow);
4250        bp.metadata.strict_verdict_handling = Some(true);
4251        assert!(
4252            Compiler::new(registry_with_echo()).compile(&bp).is_ok(),
4253            "strict mode must accept an `In` haystack that covers every declared value"
4254        );
4255    }
4256
4257    /// GH #50 follow-up (issue `33bc825b`): under strict mode, a `part`
4258    /// channel contract with unhandled declared value is rejected the same
4259    /// way as the `body` channel case. Confirms channel-agnostic coverage.
4260    #[test]
4261    fn strict_mode_rejects_unhandled_part_channel_value() {
4262        let agent = gate_agent(Some(part_contract(&["PASS", "BLOCKED"])));
4263        let flow = FlowNode::Seq {
4264            children: vec![
4265                step("gate", "$.gate"),
4266                branch(eq_cond("$.gate.parts.verdict", "BLOCKED"), noop(), noop()),
4267            ],
4268        };
4269        let mut bp = minimal_bp(agent, flow);
4270        bp.metadata.strict_verdict_handling = Some(true);
4271        match Compiler::new(registry_with_echo()).compile(&bp) {
4272            Err(CompileError::VerdictValueUnhandled {
4273                agent,
4274                value,
4275                step_ref,
4276                ..
4277            }) => {
4278                assert_eq!(agent, "gate");
4279                assert_eq!(value, "PASS");
4280                assert_eq!(step_ref, "gate");
4281            }
4282            Err(other) => {
4283                panic!("expected VerdictValueUnhandled, got a different CompileError: {other}")
4284            }
4285            Ok(_) => panic!(
4286                "expected compile-time rejection for a declared verdict value with no \
4287                 downstream handler (part channel) under strict_verdict_handling=Some(true)"
4288            ),
4289        }
4290    }
4291
4292    // ─── lints at the compile stage (design §5) ──────────────────────
4293    // Two layers: `agents[].lints` (nearer) then `metadata.lints`.
4294
4295    /// A `lints` map, as the schema's author-facing form — used for both
4296    /// the agent and the Blueprint layer.
4297    fn lints(
4298        pairs: &[(&str, mlua_swarm_schema::LintSetting)],
4299    ) -> Option<std::collections::BTreeMap<String, mlua_swarm_schema::LintSetting>> {
4300        Some(
4301            pairs
4302                .iter()
4303                .map(|(key, setting)| ((*key).to_string(), *setting))
4304                .collect(),
4305        )
4306    }
4307
4308    /// The `unhandled_gate` fixture: the contract declares
4309    /// `["PASS", "BLOCKED"]` but only "BLOCKED" is cond-referenced, so
4310    /// "PASS" is unhandled and the gate decides what happens.
4311    fn bp_with_unhandled_value() -> Blueprint {
4312        let agent = gate_agent(Some(body_contract(&["PASS", "BLOCKED"])));
4313        let flow = FlowNode::Seq {
4314            children: vec![
4315                step("gate", "$.verdict"),
4316                branch(eq_cond("$.verdict", "BLOCKED"), noop(), noop()),
4317            ],
4318        };
4319        minimal_bp(agent, flow)
4320    }
4321
4322    /// The same contract-bearing agent as [`gate_agent`] under a chosen
4323    /// name, so one agent's `lints` can be observed against a sibling
4324    /// that declares none.
4325    fn named_agent(name: &str, verdict: Option<VerdictContract>) -> AgentDef {
4326        AgentDef {
4327            name: name.to_string(),
4328            ..gate_agent(verdict)
4329        }
4330    }
4331
4332    /// Two contract-bearing agents, each declaring `["PASS", "BLOCKED"]`
4333    /// with only "BLOCKED" cond-referenced — so *both* have an unhandled
4334    /// "PASS" and the per-agent layer is what tells them apart.
4335    fn bp_with_two_unhandled_agents() -> Blueprint {
4336        let flow = FlowNode::Seq {
4337            children: vec![
4338                step("researcher", "$.researcher_verdict"),
4339                step("reviewer", "$.reviewer_verdict"),
4340                branch(eq_cond("$.researcher_verdict", "BLOCKED"), noop(), noop()),
4341                branch(eq_cond("$.reviewer_verdict", "BLOCKED"), noop(), noop()),
4342            ],
4343        };
4344        let mut bp = minimal_bp(
4345            named_agent("researcher", Some(body_contract(&["PASS", "BLOCKED"]))),
4346            flow,
4347        );
4348        bp.agents.push(named_agent(
4349            "reviewer",
4350            Some(body_contract(&["PASS", "BLOCKED"])),
4351        ));
4352        bp
4353    }
4354
4355    /// An `agents[].lints` deny reaches the compile stage, and reaches
4356    /// only the agent that declared it: the sibling's identical unhandled
4357    /// value stays a `tracing::warn!` (so the only error is the declaring
4358    /// agent's).
4359    #[test]
4360    fn agent_lints_deny_rejects_only_the_declaring_agent() {
4361        let mut bp = bp_with_two_unhandled_agents();
4362        bp.agents[0].lints = lints(&[(
4363            "verdict-value-unhandled",
4364            mlua_swarm_schema::LintSetting::Deny,
4365        )]);
4366        match Compiler::new(registry_with_echo()).compile(&bp) {
4367            Err(CompileError::VerdictValueUnhandled { agent, value, .. }) => {
4368                assert_eq!(agent, "researcher", "the sibling only warns");
4369                assert_eq!(value, "PASS");
4370            }
4371            Err(other) => {
4372                panic!("expected VerdictValueUnhandled, got a different CompileError: {other}")
4373            }
4374            Ok(_) => panic!(
4375                "expected compile-time rejection under \
4376                 agents[0].lints = {{\"verdict-value-unhandled\": \"deny\"}}"
4377            ),
4378        }
4379    }
4380
4381    /// Proximity: the agent layer wins outright over the Blueprint layer,
4382    /// so an agent-level `allow` silences that agent while a
4383    /// Blueprint-level `deny` still rejects its sibling.
4384    #[test]
4385    fn agent_allow_beats_blueprint_deny_for_that_agent() {
4386        let mut bp = bp_with_two_unhandled_agents();
4387        bp.metadata.lints = lints(&[(
4388            "verdict-value-unhandled",
4389            mlua_swarm_schema::LintSetting::Deny,
4390        )]);
4391        bp.agents[0].lints = lints(&[(
4392            "verdict-value-unhandled",
4393            mlua_swarm_schema::LintSetting::Allow,
4394        )]);
4395        match Compiler::new(registry_with_echo()).compile(&bp) {
4396            Err(CompileError::VerdictValueUnhandled { agent, .. }) => {
4397                assert_eq!(
4398                    agent, "reviewer",
4399                    "the allowing agent is silenced; the sibling still denies"
4400                );
4401            }
4402            Err(other) => {
4403                panic!("expected VerdictValueUnhandled, got a different CompileError: {other}")
4404            }
4405            Ok(_) => panic!("the sibling agent's Blueprint-level deny must still reject"),
4406        }
4407    }
4408
4409    /// Union toward deny at the agent layer too: the legacy strict flag
4410    /// wins over an `agents[].lints` `allow`, exactly as it does over a
4411    /// `metadata.lints` one.
4412    #[test]
4413    fn strict_flag_wins_over_agent_lints_allow() {
4414        let mut bp = bp_with_unhandled_value();
4415        bp.metadata.strict_verdict_handling = Some(true);
4416        bp.agents[0].lints = lints(&[("all", mlua_swarm_schema::LintSetting::Allow)]);
4417        match Compiler::new(registry_with_echo()).compile(&bp) {
4418            Err(CompileError::VerdictValueUnhandled { agent, .. }) => assert_eq!(agent, "gate"),
4419            Err(other) => {
4420                panic!("expected VerdictValueUnhandled, got a different CompileError: {other}")
4421            }
4422            Ok(_) => panic!(
4423                "strict_verdict_handling=Some(true) must still reject under an agent-level allow"
4424            ),
4425        }
4426    }
4427
4428    /// Within the agent layer, the category group key reaches the kind —
4429    /// same specificity ladder as the Blueprint layer.
4430    #[test]
4431    fn agent_category_key_reaches_the_kind() {
4432        let mut bp = bp_with_two_unhandled_agents();
4433        bp.agents[0].lints =
4434            lints(&[("category:suspicious", mlua_swarm_schema::LintSetting::Deny)]);
4435        match Compiler::new(registry_with_echo()).compile(&bp) {
4436            Err(CompileError::VerdictValueUnhandled { agent, .. }) => assert_eq!(
4437                agent, "researcher",
4438                "a category: group deny must reach the kind it covers, on the declaring agent"
4439            ),
4440            Err(other) => {
4441                panic!("expected VerdictValueUnhandled, got a different CompileError: {other}")
4442            }
4443            Ok(_) => panic!("expected compile-time rejection under an agent-level category deny"),
4444        }
4445    }
4446
4447    /// An agent that declares nothing inherits the Blueprint layer, and
4448    /// an agent-level `allow` does not leak onto it.
4449    #[test]
4450    fn agent_without_lints_inherits_the_blueprint_layer() {
4451        let mut bp = bp_with_two_unhandled_agents();
4452        bp.metadata.lints = lints(&[("all", mlua_swarm_schema::LintSetting::Allow)]);
4453        assert!(
4454            Compiler::new(registry_with_echo()).compile(&bp).is_ok(),
4455            "a Blueprint-wide allow covers every agent that declares nothing"
4456        );
4457
4458        let gates = resolve_unhandled_verdict_gates(&bp);
4459        assert_eq!(gates.for_agent("reviewer"), UnhandledVerdictGate::Silence);
4460        assert!(gates.all_silent());
4461
4462        bp.agents[0].lints = lints(&[(
4463            "verdict-value-unhandled",
4464            mlua_swarm_schema::LintSetting::Warn,
4465        )]);
4466        let gates = resolve_unhandled_verdict_gates(&bp);
4467        assert_eq!(
4468            gates.for_agent("researcher"),
4469            UnhandledVerdictGate::Warn,
4470            "the agent's own layer wins over the Blueprint's allow"
4471        );
4472        assert_eq!(
4473            gates.for_agent("reviewer"),
4474            UnhandledVerdictGate::Silence,
4475            "the sibling keeps the Blueprint layer"
4476        );
4477        assert!(!gates.all_silent());
4478    }
4479
4480    /// `metadata.lints = {"verdict-value-unhandled": "deny"}` rejects the
4481    /// same Blueprint `strict_verdict_handling: Some(true)` rejects —
4482    /// without the legacy flag being set at all.
4483    #[test]
4484    fn lints_deny_rejects_unhandled_declared_value() {
4485        let mut bp = bp_with_unhandled_value();
4486        bp.metadata.lints = lints(&[(
4487            "verdict-value-unhandled",
4488            mlua_swarm_schema::LintSetting::Deny,
4489        )]);
4490        match Compiler::new(registry_with_echo()).compile(&bp) {
4491            Err(CompileError::VerdictValueUnhandled { agent, value, .. }) => {
4492                assert_eq!(agent, "gate");
4493                assert_eq!(value, "PASS");
4494            }
4495            Err(other) => {
4496                panic!("expected VerdictValueUnhandled, got a different CompileError: {other}")
4497            }
4498            Ok(_) => panic!(
4499                "expected compile-time rejection under \
4500                 metadata.lints = {{\"verdict-value-unhandled\": \"deny\"}}"
4501            ),
4502        }
4503    }
4504
4505    /// The kind's category group key reaches it too — `verdict-value-
4506    /// unhandled` is `LintCategory::Suspicious`.
4507    #[test]
4508    fn lints_category_deny_rejects_unhandled_declared_value() {
4509        let mut bp = bp_with_unhandled_value();
4510        bp.metadata.lints = lints(&[("category:suspicious", mlua_swarm_schema::LintSetting::Deny)]);
4511        assert!(
4512            matches!(
4513                Compiler::new(registry_with_echo()).compile(&bp),
4514                Err(CompileError::VerdictValueUnhandled { .. })
4515            ),
4516            "a category: group deny must reach the kind it covers"
4517        );
4518    }
4519
4520    /// `allow` silences the warn-only default; the compile still succeeds
4521    /// (the observable difference from the default is asserted directly on
4522    /// [`resolve_unhandled_verdict_gate`] below).
4523    #[test]
4524    fn lints_allow_compiles_and_silences_the_warn() {
4525        let mut bp = bp_with_unhandled_value();
4526        bp.metadata.lints = lints(&[(
4527            "verdict-value-unhandled",
4528            mlua_swarm_schema::LintSetting::Allow,
4529        )]);
4530        assert!(
4531            Compiler::new(registry_with_echo()).compile(&bp).is_ok(),
4532            "an allowed lint must never reject the compile"
4533        );
4534        assert_eq!(
4535            resolve_unhandled_verdict_gate(&bp.metadata),
4536            UnhandledVerdictGate::Silence
4537        );
4538    }
4539
4540    /// Union toward deny: the legacy flag wins over a `lints` `allow`, so
4541    /// an existing strict Blueprint cannot be silently softened by a broad
4542    /// `all` / `category:` key.
4543    #[test]
4544    fn strict_flag_wins_over_lints_allow() {
4545        let mut bp = bp_with_unhandled_value();
4546        bp.metadata.strict_verdict_handling = Some(true);
4547        bp.metadata.lints = lints(&[("all", mlua_swarm_schema::LintSetting::Allow)]);
4548        assert!(
4549            matches!(
4550                Compiler::new(registry_with_echo()).compile(&bp),
4551                Err(CompileError::VerdictValueUnhandled { .. })
4552            ),
4553            "strict_verdict_handling=Some(true) must still reject under a lints allow"
4554        );
4555    }
4556
4557    /// The gate's full resolution table, including the two cases the
4558    /// compile result cannot tell apart (warn vs silence).
4559    #[test]
4560    fn unhandled_verdict_gate_resolution_table() {
4561        use mlua_swarm_schema::LintSetting;
4562
4563        let gate = |strict, map| {
4564            resolve_unhandled_verdict_gate(&BlueprintMetadata {
4565                strict_verdict_handling: strict,
4566                lints: map,
4567                ..Default::default()
4568            })
4569        };
4570        let kind = "verdict-value-unhandled";
4571
4572        assert_eq!(gate(None, None), UnhandledVerdictGate::Warn);
4573        assert_eq!(gate(Some(false), None), UnhandledVerdictGate::Warn);
4574        assert_eq!(gate(Some(true), None), UnhandledVerdictGate::Deny);
4575        assert_eq!(
4576            gate(None, lints(&[(kind, LintSetting::Deny)])),
4577            UnhandledVerdictGate::Deny
4578        );
4579        assert_eq!(
4580            gate(None, lints(&[(kind, LintSetting::Warn)])),
4581            UnhandledVerdictGate::Warn
4582        );
4583        assert_eq!(
4584            gate(None, lints(&[(kind, LintSetting::Allow)])),
4585            UnhandledVerdictGate::Silence
4586        );
4587        assert_eq!(
4588            gate(Some(true), lints(&[(kind, LintSetting::Allow)])),
4589            UnhandledVerdictGate::Deny,
4590            "strict wins over allow"
4591        );
4592        // Within-layer specificity: the exact kind beats the group.
4593        assert_eq!(
4594            gate(
4595                None,
4596                lints(&[
4597                    (kind, LintSetting::Allow),
4598                    ("category:suspicious", LintSetting::Deny),
4599                ])
4600            ),
4601            UnhandledVerdictGate::Silence
4602        );
4603        // Unknown keys are meta-lint material at bp_doctor, never a
4604        // compile-stage signal.
4605        assert_eq!(
4606            gate(None, lints(&[("no-such-lint", LintSetting::Deny)])),
4607            UnhandledVerdictGate::Warn
4608        );
4609    }
4610
4611    /// `metadata.lints` has exactly one compile-stage effect. Every other
4612    /// `CompileError` is a hard error, not a lint: a blanket `all` allow
4613    /// leaves them rejecting (design §3 non-suppressible boundary).
4614    #[test]
4615    fn lints_never_soften_other_compile_errors() {
4616        let mut bp = bp_with_unhandled_value();
4617        bp.agents.push(gate_agent(None));
4618        bp.metadata.lints = lints(&[("all", mlua_swarm_schema::LintSetting::Allow)]);
4619        assert!(
4620            matches!(
4621                Compiler::new(registry_with_echo()).compile(&bp),
4622                Err(CompileError::DuplicateAgent(name)) if name == "gate"
4623            ),
4624            "an `all` allow must not suppress a compile hard error"
4625        );
4626    }
4627
4628    /// Acceptance criterion #7 (5th case): a Blueprint shaped like the
4629    /// existing `02-verdict-loop.json` sample — a `Loop` retrying while
4630    /// `$.verdict == "BLOCKED"` plus a `Branch` on `$.verdict == "PASS"` —
4631    /// but with `verdict` omitted on every agent must compile unchanged
4632    /// (at most `tracing::warn!`) and leave `CompiledAgentTable.
4633    /// verdict_contracts` empty.
4634    #[test]
4635    fn verdict_omitted_blueprint_compiles_unchanged_with_empty_contracts() {
4636        let agent = gate_agent(None);
4637        let flow = FlowNode::Seq {
4638            children: vec![
4639                step("gate", "$.verdict"),
4640                FlowNode::Loop {
4641                    counter: Expr::Path {
4642                        at: "$.n".parse().expect("literal test path"),
4643                    },
4644                    cond: eq_cond("$.verdict", "BLOCKED"),
4645                    body: Box::new(step("gate", "$.verdict")),
4646                    max: 3,
4647                },
4648                branch(eq_cond("$.verdict", "PASS"), noop(), noop()),
4649            ],
4650        };
4651        let bp = minimal_bp(agent, flow);
4652        let compiled = Compiler::new(registry_with_echo())
4653            .compile(&bp)
4654            .expect("a verdict-omitted Blueprint must compile unchanged");
4655        assert!(
4656            compiled.router.verdict_contracts.is_empty(),
4657            "no agent declared a verdict contract"
4658        );
4659    }
4660
4661    // ─── GH #79 Phase 2: CompileError → Diagnostic projection ────────
4662
4663    /// Every `kind` key the `From<&CompileError>` impl can emit must be
4664    /// declared in `mlua_swarm_diag::LINT_DECLS` (the exhaustiveness of
4665    /// the variant mapping itself is enforced by the compiler — the
4666    /// `match` in the impl has no wildcard arm).
4667    #[test]
4668    fn every_compile_error_diagnostic_kind_is_a_declared_lint() {
4669        let kinds = [
4670            "bound-agent-resolution",
4671            "unknown-agent-kind",
4672            "invalid-agent-spec",
4673            "worker-binding-missing",
4674            "unresolved-agent-ref",
4675            "duplicate-agent-name",
4676            "unresolved-operator-ref",
4677            "unresolved-meta-ref",
4678            "step-naming-collision",
4679            "invalid-projection-placement",
4680            "unresolved-audit-agent",
4681            "verdict-channel-mismatch",
4682            "verdict-value-not-in-contract",
4683            "verdict-value-unhandled",
4684            "removed-spawner-hint",
4685        ];
4686        for kind in kinds {
4687            assert!(
4688                mlua_swarm_diag::lint_decl(kind).is_some(),
4689                "kind '{kind}' emitted by From<&CompileError> has no LINT_DECLS entry"
4690            );
4691        }
4692    }
4693
4694    #[test]
4695    fn invalid_spec_with_worker_binding_prefix_specializes_the_diagnostic_kind() {
4696        // The factory's message construction and the From matcher share
4697        // WORKER_BINDING_REQUIRED_MSG_PREFIX, so building the error the
4698        // way the factory does must hit the specialized arm.
4699        let err = CompileError::InvalidSpec {
4700            name: "greeter".into(),
4701            msg: format!("{WORKER_BINDING_REQUIRED_MSG_PREFIX}. Fix by either: (a) ..."),
4702        };
4703        let d = mlua_swarm_diag::Diagnostic::from(&err);
4704        assert_eq!(d.kind, "worker-binding-missing");
4705        assert_eq!(d.level, mlua_swarm_diag::DiagLevel::Error);
4706        assert!(matches!(d.stage, mlua_swarm_diag::DiagStage::CompileLint));
4707        assert!(d.message.contains("greeter"));
4708        let suggestion = d
4709            .suggestion
4710            .expect("specialized arm must carry a suggestion");
4711        assert!(suggestion.patch.contains("backend = \"ws_operator\""));
4712        assert_eq!(
4713            suggestion.applicability,
4714            mlua_swarm_diag::Applicability::HasPlaceholders
4715        );
4716        assert_eq!(
4717            d.docs_ref.expect("docs_ref must be set").uri,
4718            "mse://guides/bp-dsl-templates"
4719        );
4720        match d.span.expect("span must be set").element {
4721            mlua_swarm_diag::DiagElement::Agent { name } => assert_eq!(name, "greeter"),
4722            other => panic!("expected Agent span, got {other:?}"),
4723        }
4724    }
4725
4726    // ─── Removed spawner-hint layer (`operator_delegate`) ────────────
4727    //
4728    // The delegate axis is gone. `service::linker::link` skips hint keys
4729    // the deployment does not install, so without an explicit refusal a
4730    // Blueprint that still declares the key would compile, register, and
4731    // run a different execution shape in silence. These pin the refusal,
4732    // the fact that ordinary unknown keys keep their leniency, and the
4733    // author-facing content of the diagnostic.
4734
4735    fn bp_with_hint_layers(layers: &[&str]) -> Blueprint {
4736        let mut bp = minimal_bp(gate_agent(None), step("gate", "$.verdict"));
4737        bp.spawner_hints = mlua_swarm_schema::SpawnerHints {
4738            layers: layers.iter().map(|s| s.to_string()).collect(),
4739        };
4740        bp
4741    }
4742
4743    #[test]
4744    fn declaring_the_removed_operator_delegate_layer_fails_the_compile() {
4745        let bp = bp_with_hint_layers(&["operator_delegate"]);
4746        match Compiler::new(registry_with_echo()).compile(&bp) {
4747            Err(CompileError::RemovedSpawnerHint { key, reason }) => {
4748                assert_eq!(key, "operator_delegate");
4749                assert!(
4750                    reason.contains("Run.current") && reason.contains("system"),
4751                    "the reason must name both defects the removal was for, so the error \
4752                     alone explains why: {reason}"
4753                );
4754            }
4755            Err(other) => panic!("expected RemovedSpawnerHint, got: {other}"),
4756            Ok(_) => panic!(
4757                "a Blueprint declaring the removed operator_delegate layer must not compile — \
4758                 otherwise the layer silently stops applying"
4759            ),
4760        }
4761    }
4762
4763    /// The leniency that still applies: a key this deployment does not
4764    /// install is skipped, not refused. Removing a layer must not turn
4765    /// `spawner_hints.layers` into a closed set, or every Blueprint
4766    /// written against a deployment with extra layers stops compiling
4767    /// here.
4768    #[test]
4769    fn an_unknown_but_not_removed_layer_key_still_compiles() {
4770        let bp = bp_with_hint_layers(&["main_ai", "some_deployment_specific_layer"]);
4771        Compiler::new(registry_with_echo())
4772            .compile(&bp)
4773            .expect("unknown (as opposed to removed) hint keys stay leniently skipped");
4774    }
4775
4776    #[test]
4777    fn a_blueprint_declaring_no_layers_compiles() {
4778        let bp = bp_with_hint_layers(&[]);
4779        Compiler::new(registry_with_echo())
4780            .compile(&bp)
4781            .expect("the all-clear case must produce no finding at all");
4782    }
4783
4784    #[test]
4785    fn removed_spawner_hint_projects_a_migration_diagnostic_naming_the_replacement() {
4786        let err = CompileError::RemovedSpawnerHint {
4787            key: "operator_delegate".into(),
4788            reason: "it could not follow a handover".into(),
4789        };
4790        let d = mlua_swarm_diag::Diagnostic::from(&err);
4791
4792        assert_eq!(d.kind, "removed-spawner-hint");
4793        assert_eq!(d.level, mlua_swarm_diag::DiagLevel::Error);
4794        assert!(matches!(d.stage, mlua_swarm_diag::DiagStage::CompileLint));
4795
4796        // The kind resolves in the registry, and is filed as a migration
4797        // (a deprecated surface with a replacement) rather than a plain
4798        // correctness bug.
4799        let decl = mlua_swarm_diag::lint_decl("removed-spawner-hint")
4800            .expect("the kind must be declared in LINT_DECLS");
4801        assert_eq!(decl.category, mlua_swarm_diag::LintCategory::Migration);
4802
4803        // What the author is actually told: where to go instead.
4804        let help = d.help.as_ref().expect("help must name the replacement");
4805        assert!(
4806            help.contains("operators[]")
4807                && help.contains("spec.operator_ref")
4808                && help.contains("operator_sid"),
4809            "the help line must name all three parts of the AgentSpec axis an author has to \
4810             write, not just say the old one is gone: {help}"
4811        );
4812
4813        // Whole-value equality against the shared constructor, not
4814        // `patch.contains("operator_ref")`: the substring form passes on
4815        // any two texts that both mention the field, which is exactly what
4816        // a drifted copy of this prose would do. `bp_doctor`'s arm calls
4817        // the same constructor, so this also pins the two stages together
4818        // — the failure mode named in
4819        // `removed_spawner_hint_suggestion`'s doc.
4820        let suggestion = d.suggestion.expect("a concrete patch must be attached");
4821        assert_eq!(
4822            suggestion,
4823            mlua_swarm_diag::removed_spawner_hint_suggestion()
4824        );
4825        assert!(
4826            suggestion.patch.contains("\"operator_ref\""),
4827            "whatever else the shared patch says, it has to show the field an author \
4828             must add: {}",
4829            suggestion.patch
4830        );
4831        assert_eq!(
4832            suggestion.applicability,
4833            // Not MachineApplicable: the author has to pick which agents
4834            // get an `operator_ref` and what the seat is called.
4835            mlua_swarm_diag::Applicability::HasPlaceholders
4836        );
4837
4838        assert_eq!(
4839            d.docs_ref.expect("docs_ref must be set").uri,
4840            "mse://guides/blueprint-authoring"
4841        );
4842        assert!(matches!(
4843            d.span.expect("span must be set").element,
4844            mlua_swarm_diag::DiagElement::BlueprintRoot
4845        ));
4846    }
4847
4848    #[test]
4849    fn generic_invalid_spec_maps_to_the_generic_kind() {
4850        let err = CompileError::InvalidSpec {
4851            name: "solo".into(),
4852            msg: "operator spec: 'operator_ref' (string) required".into(),
4853        };
4854        let d = mlua_swarm_diag::Diagnostic::from(&err);
4855        assert_eq!(d.kind, "invalid-agent-spec");
4856        assert!(
4857            d.suggestion.is_none(),
4858            "generic arm carries no canned patch"
4859        );
4860    }
4861
4862    #[test]
4863    fn verdict_value_not_in_contract_diagnostic_carries_suggestion_and_span() {
4864        let err = CompileError::VerdictValueNotInContract {
4865            where_: "Branch cond".into(),
4866            agent: "review".into(),
4867            value: "NOT_DECLARED".into(),
4868            values: vec!["PASS".into(), "BLOCKED".into()],
4869        };
4870        let d = mlua_swarm_diag::Diagnostic::from(&err);
4871        assert_eq!(d.kind, "verdict-value-not-in-contract");
4872        assert!(d.message.contains("NOT_DECLARED"));
4873        assert!(d.suggestion.is_some());
4874        match d.span.expect("span must be set").element {
4875            mlua_swarm_diag::DiagElement::Agent { name } => assert_eq!(name, "review"),
4876            other => panic!("expected Agent span, got {other:?}"),
4877        }
4878    }
4879}
4880
4881// ─── GH #83: SubprocessDef template hint + placeholder validation ─────────
4882#[cfg(test)]
4883mod subprocess_embed_compile_tests {
4884    use super::*;
4885    use mlua_swarm_schema::{current_schema_version, SubprocessDef, SubprocessOverrides};
4886
4887    fn subprocess_agent(name: &str, runner: Option<Runner>) -> AgentDef {
4888        AgentDef {
4889            name: name.to_string(),
4890            kind: AgentKind::Subprocess,
4891            spec: serde_json::json!({}),
4892            profile: Some(AgentProfile {
4893                system_prompt: "you are a headless worker".to_string(),
4894                model: Some("profile-model".to_string()),
4895                tools: vec!["Read".to_string()],
4896                ..Default::default()
4897            }),
4898            meta: None,
4899            runner,
4900            runner_ref: None,
4901            verdict: None,
4902            lints: None,
4903        }
4904    }
4905
4906    fn echo_def(name: &str) -> SubprocessDef {
4907        SubprocessDef {
4908            name: name.to_string(),
4909            argv: vec!["sh".to_string(), "-c".to_string(), "cat".to_string()],
4910            stdin: Some("{prompt}".to_string()),
4911            env: Default::default(),
4912            cwd: None,
4913            output: None,
4914            stream_mode: None,
4915            timeout_ms: None,
4916        }
4917    }
4918
4919    fn bp_with(agents: Vec<AgentDef>, subprocesses: Vec<SubprocessDef>) -> Blueprint {
4920        Blueprint {
4921            schema_version: current_schema_version(),
4922            id: "gh83-ut".into(),
4923            flow: FlowNode::Seq { children: vec![] },
4924            agents,
4925            operators: vec![],
4926            metas: vec![],
4927            hints: Default::default(),
4928            strategy: Default::default(),
4929            metadata: BlueprintMetadata::default(),
4930            spawner_hints: Default::default(),
4931            default_agent_kind: AgentKind::Operator,
4932            default_operator_kind: None,
4933            default_init_ctx: None,
4934            default_agent_ctx: None,
4935            default_context_policy: None,
4936            projection_placement: None,
4937            audits: vec![],
4938            degradation_policy: None,
4939            runners: vec![],
4940            default_runner: None,
4941            subprocesses,
4942            check_policy: None,
4943            blueprint_ref_includes: vec![],
4944        }
4945    }
4946
4947    fn subprocess_runner(template: &str) -> Runner {
4948        Runner::Subprocess {
4949            template: template.to_string(),
4950            overrides: SubprocessOverrides::default(),
4951        }
4952    }
4953
4954    #[test]
4955    fn validate_placeholders_accepts_closed_set_and_json_braces() {
4956        for ok in [
4957            "{system} {system_file} {prompt} {model} {tools_csv} {work_dir} {task_id} {attempt}",
4958            r#"echo '{"result": "ok", "nested": {"a": 1}}'"#,
4959            "no placeholders at all",
4960            "unmatched { brace",
4961        ] {
4962            validate_embed_placeholders(ok, "ut").expect("must be accepted");
4963        }
4964    }
4965
4966    #[test]
4967    fn validate_placeholders_rejects_unknown_token() {
4968        let err = validate_embed_placeholders("--flag {evil}", "argv[1]").unwrap_err();
4969        assert!(err.contains("'{evil}'"), "token named: {err}");
4970        assert!(err.contains("closed set"), "closed set listed: {err}");
4971    }
4972
4973    /// The scan descends into literal braces — a token nested inside a
4974    /// JSON-wrapped template string is still validated (mirrors the
4975    /// spawn-time render scan).
4976    #[test]
4977    fn validate_placeholders_descends_into_literal_braces() {
4978        validate_embed_placeholders(r#"{"task": "{prompt}"}"#, "stdin")
4979            .expect("nested closed-set token must be accepted");
4980        let err = validate_embed_placeholders(r#"{"task": "{evil}"}"#, "stdin").unwrap_err();
4981        assert!(
4982            err.contains("'{evil}'"),
4983            "nested unknown token caught: {err}"
4984        );
4985    }
4986
4987    #[test]
4988    fn hint_resolution_finds_declared_template() {
4989        let agent = subprocess_agent("headless", Some(subprocess_runner("echo")));
4990        let bp = bp_with(vec![agent.clone()], vec![echo_def("echo")]);
4991        let hint = resolve_subprocess_template_hint(&bp, &agent)
4992            .expect("resolves")
4993            .expect("Runner::Subprocess must synthesize a hint");
4994        assert_eq!(hint[SUBPROCESS_TEMPLATE_HINT_KEY]["name"], "echo");
4995        assert!(hint.get(SUBPROCESS_OVERRIDES_HINT_KEY).is_some());
4996    }
4997
4998    #[test]
4999    fn hint_resolution_unknown_template_is_invalid_spec() {
5000        let agent = subprocess_agent("headless", Some(subprocess_runner("nope")));
5001        let bp = bp_with(vec![agent.clone()], vec![echo_def("echo")]);
5002        let err = resolve_subprocess_template_hint(&bp, &agent).unwrap_err();
5003        let msg = format!("{err}");
5004        assert!(msg.contains("'nope'"), "missing template named: {msg}");
5005        assert!(msg.contains("echo"), "defined templates listed: {msg}");
5006    }
5007
5008    #[test]
5009    fn hint_resolution_none_without_subprocess_runner() {
5010        let agent = subprocess_agent("headless", None);
5011        let bp = bp_with(vec![agent.clone()], vec![echo_def("echo")]);
5012        let hint = resolve_subprocess_template_hint(&bp, &agent).expect("resolves");
5013        assert!(hint.is_none(), "spec-based agents keep the historical path");
5014    }
5015
5016    // ─── GH #86: AgentBlock tool-grant hint ───────────────────────────────
5017    //
5018    // Sibling of the `resolve_subprocess_template_hint` cases above; the
5019    // shared `bp_with` / `Blueprint` fixture is why these live in the same
5020    // module rather than a third one.
5021
5022    fn agent_block_agent(name: &str, runner: Option<Runner>, profile_tools: &[&str]) -> AgentDef {
5023        AgentDef {
5024            name: name.to_string(),
5025            kind: AgentKind::AgentBlock,
5026            spec: serde_json::json!({}),
5027            profile: Some(AgentProfile {
5028                system_prompt: "you are an in-process auditor".to_string(),
5029                tools: profile_tools.iter().map(|t| t.to_string()).collect(),
5030                ..Default::default()
5031            }),
5032            meta: None,
5033            runner,
5034            runner_ref: None,
5035            verdict: None,
5036            lints: None,
5037        }
5038    }
5039
5040    fn agent_block_runner(tools: &[&str]) -> Runner {
5041        Runner::AgentBlockInProcess {
5042            tools: tools.iter().map(|t| t.to_string()).collect(),
5043        }
5044    }
5045
5046    /// The AgentBlock tool grant reaches the factory through the
5047    /// `BoundAgent` projection, NOT through a build hint: a declared
5048    /// `Runner::AgentBlockInProcess` overwrites `profile.tools` with its own
5049    /// list. Asserting on the projection is what pins the contract, since a
5050    /// hint for this axis would bypass the pinned snapshot on resume.
5051    #[test]
5052    fn agent_block_runner_tools_are_projected_over_profile_tools() {
5053        let agent = agent_block_agent(
5054            "auditor",
5055            Some(agent_block_runner(&["mcp__outline__list_docs"])),
5056            &["Read"],
5057        );
5058        let bp = bp_with(vec![agent], vec![]);
5059        let bound = resolve_bound_agents(&bp).expect("binds");
5060        let effective = materialize_bound_blueprint(&bp, &bound);
5061        assert_eq!(
5062            effective.agents[0].profile.as_ref().unwrap().tools,
5063            vec!["mcp__outline__list_docs".to_string()],
5064            "the declared Runner tools replace profile.tools (['Read'])"
5065        );
5066    }
5067
5068    /// A declared-but-empty `tools` list is an enforced-empty grant: the
5069    /// projection must still overwrite, or an agent.md's inherited `tools:`
5070    /// line would silently survive a Blueprint that meant to revoke it.
5071    #[test]
5072    fn agent_block_projection_distinguishes_declared_empty_from_absent() {
5073        let declared = agent_block_agent("auditor", Some(agent_block_runner(&[])), &["Read"]);
5074        let bp = bp_with(vec![declared], vec![]);
5075        let bound = resolve_bound_agents(&bp).expect("binds");
5076        let effective = materialize_bound_blueprint(&bp, &bound);
5077        assert!(
5078            effective.agents[0]
5079                .profile
5080                .as_ref()
5081                .unwrap()
5082                .tools
5083                .is_empty(),
5084            "empty means enforced-empty, not 'unset'"
5085        );
5086
5087        let absent = agent_block_agent("auditor", None, &["Read"]);
5088        let bp = bp_with(vec![absent], vec![]);
5089        let bound = resolve_bound_agents(&bp).expect("binds");
5090        let effective = materialize_bound_blueprint(&bp, &bound);
5091        assert_eq!(
5092            effective.agents[0].profile.as_ref().unwrap().tools,
5093            vec!["Read".to_string()],
5094            "no Runner declared → the agent.md tools line stands"
5095        );
5096    }
5097
5098    /// End-to-end through `Compiler::compile`: the projected grant reaches
5099    /// `AgentBlockInProcessSpawnerFactory::build`, whose ScriptBasedAgent
5100    /// guard rejects an unenforceable MCP grant. A successful build returns
5101    /// an opaque `Arc<dyn SpawnerAdapter>`, so this negative path is the
5102    /// compile-level assertion available; the positive paths are covered in
5103    /// `worker::agent_block::runtime`'s tests.
5104    #[test]
5105    fn compile_rejects_script_mode_with_a_declared_mcp_grant() {
5106        let mut agent = agent_block_agent(
5107            "auditor",
5108            Some(agent_block_runner(&["mcp__outline__list_docs"])),
5109            &[],
5110        );
5111        agent.spec = serde_json::json!({ "script_path": "gate.lua" });
5112        let mut bp = bp_with(vec![agent], vec![]);
5113        bp.strategy.strict_refs = false;
5114
5115        let mut registry = SpawnerRegistry::new();
5116        registry.register::<crate::worker::agent_block::AgentBlockInProcessSpawnerFactory>(
5117            Arc::new(crate::worker::agent_block::AgentBlockInProcessSpawnerFactory::new()),
5118        );
5119        // `CompiledBlueprint` is not `Debug`, so `expect_err` is unavailable.
5120        let err = match Compiler::new(registry).compile(&bp) {
5121            Err(e) => e,
5122            Ok(_) => panic!("script mode + declared MCP grant must not compile"),
5123        };
5124        let msg = format!("{err}");
5125        assert!(msg.contains("script_path"), "names the trigger: {msg}");
5126        assert!(
5127            msg.contains("mcp__outline__list_docs"),
5128            "names the unenforceable tools: {msg}"
5129        );
5130    }
5131
5132    /// The guard must not catch a script-mode agent whose tools are all
5133    /// inert (non-`mcp__`) — that shape compiled before the guard existed
5134    /// and grants nothing this backend can enforce either way.
5135    #[test]
5136    fn compile_accepts_script_mode_with_only_inert_tools() {
5137        let mut agent = agent_block_agent("auditor", None, &["Read", "WebSearch"]);
5138        agent.spec = serde_json::json!({ "script_path": "gate.lua" });
5139        let mut bp = bp_with(vec![agent], vec![]);
5140        bp.strategy.strict_refs = false;
5141
5142        let mut registry = SpawnerRegistry::new();
5143        registry.register::<crate::worker::agent_block::AgentBlockInProcessSpawnerFactory>(
5144            Arc::new(crate::worker::agent_block::AgentBlockInProcessSpawnerFactory::new()),
5145        );
5146        if let Err(e) = Compiler::new(registry).compile(&bp) {
5147            panic!("inert tools must not trip the MCP-grant guard: {e}");
5148        }
5149    }
5150
5151    #[test]
5152    fn build_embed_rejects_unknown_placeholder() {
5153        let agent = subprocess_agent("headless", None);
5154        let mut def = echo_def("echo");
5155        def.argv.push("--x={evil}".to_string());
5156        let err = SubprocessProcessSpawnerFactory::build_embed(
5157            &agent,
5158            &serde_json::to_value(&def).unwrap(),
5159            None,
5160        )
5161        .unwrap_err();
5162        assert!(format!("{err}").contains("'{evil}'"));
5163    }
5164
5165    #[test]
5166    fn build_embed_rejects_output_with_stream_mode() {
5167        let agent = subprocess_agent("headless", None);
5168        let mut def = echo_def("echo");
5169        def.stream_mode = Some("ndjson_lines".to_string());
5170        def.output = Some(mlua_swarm_schema::SubprocessOutput {
5171            format: Some("json".to_string()),
5172            result_ptr: None,
5173            ok_from: None,
5174            stats: None,
5175        });
5176        let err = SubprocessProcessSpawnerFactory::build_embed(
5177            &agent,
5178            &serde_json::to_value(&def).unwrap(),
5179            None,
5180        )
5181        .unwrap_err();
5182        assert!(format!("{err}").contains("plain-mode"));
5183    }
5184
5185    #[test]
5186    fn build_embed_rejects_malformed_result_ptr_and_ok_from() {
5187        let agent = subprocess_agent("headless", None);
5188        let mut def = echo_def("echo");
5189        def.output = Some(mlua_swarm_schema::SubprocessOutput {
5190            format: None,
5191            result_ptr: Some("result".to_string()),
5192            ok_from: None,
5193            stats: None,
5194        });
5195        let err = SubprocessProcessSpawnerFactory::build_embed(
5196            &agent,
5197            &serde_json::to_value(&def).unwrap(),
5198            None,
5199        )
5200        .unwrap_err();
5201        assert!(format!("{err}").contains("JSON Pointer"));
5202
5203        let mut def = echo_def("echo");
5204        def.output = Some(mlua_swarm_schema::SubprocessOutput {
5205            format: None,
5206            result_ptr: None,
5207            ok_from: Some("status".to_string()),
5208            stats: None,
5209        });
5210        let err = SubprocessProcessSpawnerFactory::build_embed(
5211            &agent,
5212            &serde_json::to_value(&def).unwrap(),
5213            None,
5214        )
5215        .unwrap_err();
5216        assert!(format!("{err}").contains("exit_code"));
5217    }
5218
5219    #[test]
5220    fn build_embed_bakes_profile_with_override_precedence() {
5221        let agent = subprocess_agent("headless", None);
5222        let def = echo_def("echo");
5223        let overrides = SubprocessOverrides {
5224            model: Some("override-model".to_string()),
5225            tools: vec!["Bash".to_string(), "Write".to_string()],
5226            cwd: Some("/tmp/override-wd".to_string()),
5227        };
5228        let sp = SubprocessProcessSpawnerFactory::build_embed(
5229            &agent,
5230            &serde_json::to_value(&def).unwrap(),
5231            Some(&serde_json::to_value(&overrides).unwrap()),
5232        )
5233        .expect("builds");
5234        let embed = sp.embed.as_ref().expect("embed template baked");
5235        assert_eq!(embed.model.as_deref(), Some("override-model"));
5236        assert_eq!(embed.tools_csv, "Bash,Write");
5237        assert_eq!(embed.cwd.as_deref(), Some("/tmp/override-wd"));
5238        assert_eq!(
5239            embed.system_prompt.as_deref(),
5240            Some("you are a headless worker")
5241        );
5242    }
5243}