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::Assign { .. } => {} // The Assign 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::Assign { .. } => {} // The Assign 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::Assign { .. } => {} // The Assign 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::Assign { .. } => {}
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 let Some(output) = &def.output {
1958            if stream_mode.is_some() {
1959                return Err(invalid(format!(
1960                    "SubprocessDef '{}': output normalization is a plain-mode \
1961                     declaration; remove either `output` or `stream_mode`",
1962                    def.name
1963                )));
1964            }
1965            if let Some(format) = output.format.as_deref() {
1966                if format != "json" {
1967                    return Err(invalid(format!(
1968                        "SubprocessDef '{}': unknown output.format '{format}' \
1969                         (supported: \"json\")",
1970                        def.name
1971                    )));
1972                }
1973            }
1974            if let Some(ptr) = output.result_ptr.as_deref() {
1975                if !ptr.starts_with('/') {
1976                    return Err(invalid(format!(
1977                        "SubprocessDef '{}': output.result_ptr '{ptr}' is not a \
1978                         JSON Pointer (RFC 6901 — must start with '/')",
1979                        def.name
1980                    )));
1981                }
1982            }
1983            if let Some(ok_from) = output.ok_from.as_deref() {
1984                if ok_from != "exit_code" && !ok_from.starts_with('/') {
1985                    return Err(invalid(format!(
1986                        "SubprocessDef '{}': output.ok_from '{ok_from}' must be \
1987                         \"exit_code\" or a JSON Pointer (starting with '/')",
1988                        def.name
1989                    )));
1990                }
1991            }
1992        }
1993
1994        // Compile-time profile bake — same shape as OperatorSpawnerFactory,
1995        // with Runner::Subprocess overrides winning over the profile.
1996        let profile = agent_def.profile.as_ref();
1997        let system_prompt = profile
1998            .map(|p| p.system_prompt.clone())
1999            .filter(|s| !s.is_empty());
2000        let model = overrides
2001            .model
2002            .clone()
2003            .or_else(|| profile.and_then(|p| p.model.clone()));
2004        let tools: Vec<String> = if overrides.tools.is_empty() {
2005            profile.map(|p| p.tools.clone()).unwrap_or_default()
2006        } else {
2007            overrides.tools.clone()
2008        };
2009        // overrides.cwd wins over the template's own cwd.
2010        let cwd = overrides.cwd.clone().or_else(|| def.cwd.clone());
2011        if let Some(c) = &cwd {
2012            validate_embed_placeholders(c, "overrides.cwd").map_err(&invalid)?;
2013        }
2014
2015        let program = def.argv[0].clone();
2016        let sp = ProcessSpawner {
2017            program,
2018            args: Vec::new(),
2019            use_stdin: def.stdin.is_some(),
2020            stream_mode,
2021            embed: Some(EmbedTemplate {
2022                argv: def.argv,
2023                stdin: def.stdin,
2024                env: def.env,
2025                cwd,
2026                output: def.output,
2027                system_prompt,
2028                model,
2029                tools_csv: tools.join(","),
2030            }),
2031        };
2032        Ok(sp)
2033    }
2034}
2035
2036impl SpawnerFactory for SubprocessProcessSpawnerFactory {
2037    fn build(
2038        &self,
2039        agent_def: &AgentDef,
2040        hint: Option<&Value>,
2041    ) -> Result<Arc<dyn SpawnerAdapter>, CompileError> {
2042        // GH #83: EmbedAgent template mode when the compile-synthesized
2043        // hint is present; the spec-based path below is byte-for-byte
2044        // unchanged otherwise.
2045        if let Some(template) = hint.and_then(|h| h.get(SUBPROCESS_TEMPLATE_HINT_KEY)) {
2046            let overrides = hint.and_then(|h| h.get(SUBPROCESS_OVERRIDES_HINT_KEY));
2047            return Self::build_embed(agent_def, template, overrides).map(|sp| {
2048                let arc: Arc<dyn SpawnerAdapter> = Arc::new(sp);
2049                arc
2050            });
2051        }
2052        let agent_name = &agent_def.name;
2053        let spec = &agent_def.spec;
2054        let invalid = |msg: String| CompileError::InvalidSpec {
2055            name: agent_name.to_string(),
2056            msg,
2057        };
2058        let program = spec
2059            .get("program")
2060            .and_then(|v| v.as_str())
2061            .ok_or_else(|| invalid("shell spec: 'program' (string) required".into()))?
2062            .to_string();
2063        let args: Vec<String> = spec
2064            .get("args")
2065            .and_then(|v| v.as_array())
2066            .map(|a| {
2067                a.iter()
2068                    .filter_map(|x| x.as_str().map(|s| s.to_string()))
2069                    .collect()
2070            })
2071            .unwrap_or_default();
2072        let use_stdin = spec
2073            .get("use_stdin")
2074            .and_then(|v| v.as_bool())
2075            .unwrap_or(true);
2076        let stream_mode = match spec.get("stream_mode").and_then(|v| v.as_str()) {
2077            Some("ndjson_lines") => Some(StreamMode::NdjsonLines),
2078            Some("sse_events") => Some(StreamMode::SseEvents),
2079            Some("length_prefixed") => Some(StreamMode::LengthPrefixed),
2080            Some(other) => return Err(invalid(format!("unknown stream_mode: {other}"))),
2081            None => None,
2082        };
2083
2084        let mut sp = ProcessSpawner {
2085            program,
2086            args,
2087            use_stdin,
2088            stream_mode,
2089            embed: None,
2090        };
2091        if let Some(mode) = sp.stream_mode.clone() {
2092            sp = sp.stream_mode(mode);
2093        }
2094        Ok(Arc::new(sp))
2095    }
2096}
2097
2098/// Factory for `AgentKind::Lua`. At `build` time it inspects the
2099/// `AgentDef.spec` and returns an [`InProcSpawner`] with the Lua-eval
2100/// `WorkerFn` registered under `agent_name` — one `InProcSpawner`
2101/// instance per agent.
2102///
2103/// Naming convention: `<WorkerIMPL><AdapterType>SpawnerFactory` (Lua
2104/// worker on InProcess adapter). One half of the old
2105/// `InProcSpawnerFactory`, split into Lua and RustFn variants.
2106///
2107/// Spec shape (choose one; `source` wins when both are present):
2108///
2109/// ```jsonc
2110/// // (a) Registry lookup — Lua source id pre-registered with the
2111/// //     factory via `register_lua` (used by the enhance flow's built-in
2112/// //     workers). Requires the factory to know the id at construction
2113/// //     time.
2114/// { "fn_id": "patch-spawner" }
2115///
2116/// // (b) Inline source — a Lua chunk carried by the Blueprint itself,
2117/// //     wrapped on the fly at `build` time. Combined with the loader's
2118/// //     `$file` ref expansion (`"source": {"$file": "gates/foo.lua"}`)
2119/// //     this lets a BP ship deterministic Lua gates without any
2120/// //     pre-registration. `label` is optional and defaults to
2121/// //     `"<agent_name>.lua"` for error messages.
2122/// { "source": "return { value = 42, ok = true }",
2123///   "label": "psim-gate.lua" }
2124/// ```
2125///
2126/// Host bridges registered on the factory (see [`Self::with_bridge`])
2127/// apply to both spec shapes.
2128pub struct LuaInProcessSpawnerFactory {
2129    registry: HashMap<String, WorkerFn>,
2130    bridges: HashMap<String, HostBridge>,
2131}
2132
2133/// Rust-side bridge function callable from Lua.
2134///
2135/// Inputs and outputs are both `serde_json::Value` (i.e. JSON). Lua
2136/// invokes it as `host.<name>(arg_table)`. If the implementation needs
2137/// to call async Rust, the caller does the sync-ification (typically
2138/// `tokio::runtime::Handle::current().block_on(...)`).
2139///
2140/// Design intent: keep Lua scripts focused on flow control and `ctx`
2141/// walking, while the heavy lifting (LLM calls, RFC 6902 apply,
2142/// verifiers, and so on) stays on the Rust side. Going "pure Lua" —
2143/// removing the bridge — is a carry.
2144#[derive(Clone)]
2145pub struct HostBridge(
2146    Arc<dyn Fn(serde_json::Value) -> Result<serde_json::Value, String> + Send + Sync>,
2147);
2148
2149impl HostBridge {
2150    /// Wrap a Rust closure as a bridge callable from Lua.
2151    pub fn new<F>(f: F) -> Self
2152    where
2153        F: Fn(serde_json::Value) -> Result<serde_json::Value, String> + Send + Sync + 'static,
2154    {
2155        Self(Arc::new(f))
2156    }
2157
2158    /// Invoke the bridge directly — a thin trampoline over the inner
2159    /// `Fn`. The production path goes through the Lua runtime, but this
2160    /// stays `pub` so unit tests can exercise the primitive directly.
2161    pub fn call(&self, arg: serde_json::Value) -> Result<serde_json::Value, String> {
2162        (self.0)(arg)
2163    }
2164}
2165
2166/// Carrier type for Lua script sources. Paths are not required — a
2167/// source string plus an identifying label is all it holds.
2168///
2169/// Callers bring in the source (via `include_str!` or similar) and
2170/// register it with the factory through
2171/// [`LuaInProcessSpawnerFactory::register_lua`].
2172#[derive(Clone)]
2173pub struct LuaScriptSource {
2174    /// The Lua chunk source.
2175    pub source: String,
2176    /// Label used in error messages — typically the script's logical id
2177    /// (for example `"patch_spawner.lua"`).
2178    pub label: String,
2179}
2180
2181impl LuaScriptSource {
2182    /// Wrap a Lua chunk source and its error-message label.
2183    pub fn new(source: impl Into<String>, label: impl Into<String>) -> Self {
2184        Self {
2185            source: source.into(),
2186            label: label.into(),
2187        }
2188    }
2189}
2190
2191impl LuaInProcessSpawnerFactory {
2192    /// Start with no registered scripts and no host bridges.
2193    pub fn new() -> Self {
2194        Self {
2195            registry: HashMap::new(),
2196            bridges: HashMap::new(),
2197        }
2198    }
2199
2200    /// Register a host bridge. Subsequent `register_lua` calls snapshot
2201    /// the current bridge set.
2202    ///
2203    /// Ordering rule: register bridges first, then call `register_lua`;
2204    /// bridges added after `register_lua` will not be visible to that
2205    /// script.
2206    pub fn with_bridge(mut self, name: impl Into<String>, bridge: HostBridge) -> Self {
2207        self.bridges.insert(name.into(), bridge);
2208        self
2209    }
2210
2211    /// Register a **Lua-eval Worker** under `fn_id`.
2212    ///
2213    /// Each dispatch spins up a fresh `mlua::Lua` VM, injects globals
2214    /// (`_PROMPT` / `_AGENT` / `_TASK_ID` / `_ATTEMPT` / `_CTX` — the last
2215    /// is `_PROMPT` parsed as JSON, or `nil` if that fails), evaluates
2216    /// the script, and marshals the returned table into a `WorkerResult`.
2217    ///
2218    /// Marshalling rules for the return value:
2219    /// - `{ value = ..., ok = bool }` → `WorkerResult.value` /
2220    ///   `WorkerResult.ok` verbatim.
2221    /// - Anything else → `value = <returned value>`, `ok = true`.
2222    ///
2223    /// Execution runs on `tokio::task::spawn_blocking` because `mlua::Lua`
2224    /// is `!Send` and needs to stay away from the tokio async context.
2225    /// Host bridges (the Lua-to-Rust callback path) previously registered
2226    /// with [`Self::with_bridge`] are snapshotted at call time and
2227    /// injected into every dispatch inside `run_lua_worker`.
2228    pub fn register_lua(mut self, fn_id: impl Into<String>, source: LuaScriptSource) -> Self {
2229        let source = Arc::new(source);
2230        let bridges = Arc::new(self.bridges.clone());
2231        let wrapped: WorkerFn = Arc::new(move |inv| {
2232            let source = source.clone();
2233            let bridges = bridges.clone();
2234            Box::pin(run_lua_worker(source, bridges, inv))
2235        });
2236        self.registry.insert(fn_id.into(), wrapped);
2237        self
2238    }
2239}
2240
2241/// Body of a single Lua-eval invocation (called from `register_lua`).
2242async fn run_lua_worker(
2243    source: Arc<LuaScriptSource>,
2244    bridges: Arc<HashMap<String, HostBridge>>,
2245    inv: crate::worker::adapter::WorkerInvocation,
2246) -> Result<crate::worker::adapter::WorkerResult, crate::worker::adapter::WorkerError> {
2247    use crate::worker::adapter::WorkerError;
2248    use mlua::LuaSerdeExt;
2249
2250    let label = source.label.clone();
2251    let outcome =
2252        tokio::task::spawn_blocking(move || -> Result<(serde_json::Value, bool), String> {
2253            let lua = mlua::Lua::new();
2254            let g = lua.globals();
2255
2256            // 1. Base globals.
2257            g.set("_PROMPT", inv.prompt.clone())
2258                .map_err(|e| format!("set _PROMPT: {e}"))?;
2259            g.set("_AGENT", inv.agent.clone())
2260                .map_err(|e| format!("set _AGENT: {e}"))?;
2261            g.set("_TASK_ID", inv.task_id.to_string())
2262                .map_err(|e| format!("set _TASK_ID: {e}"))?;
2263            g.set("_ATTEMPT", inv.attempt as i64)
2264                .map_err(|e| format!("set _ATTEMPT: {e}"))?;
2265
2266            // 1b. GH #86: the task-context tier, off the same
2267            //     `WorkerInvocation.context` seam the AgentBlock backend
2268            //     reads, rendered through the same shared mapping
2269            //     (`context_globals`) so a Lua gate sees identical globals
2270            //     on either in-process backend and stays portable between
2271            //     them. An absent field contributes no entry, so the
2272            //     global is simply nil — the "insert nothing when absent"
2273            //     contract the rest of this axis follows.
2274            for (name, value) in
2275                crate::worker::agent_block::runtime::context_globals(inv.context.as_ref())
2276            {
2277                let lua_val = lua
2278                    .to_value(&value)
2279                    .map_err(|e| format!("{name} to_value: {e}"))?;
2280                g.set(name.as_str(), lua_val)
2281                    .map_err(|e| format!("set {name}: {e}"))?;
2282            }
2283
2284            // 2. _CTX = JSON parse(_PROMPT); nil on parse failure (co-exists with the plain-string prompt path).
2285            if let Ok(json_val) = serde_json::from_str::<serde_json::Value>(&inv.prompt) {
2286                let lua_val = lua
2287                    .to_value(&json_val)
2288                    .map_err(|e| format!("_CTX to_value: {e}"))?;
2289                g.set("_CTX", lua_val)
2290                    .map_err(|e| format!("set _CTX: {e}"))?;
2291            }
2292
2293            // 3. Inject the host bridge (Lua can call `host.<name>(arg)`).
2294            if !bridges.is_empty() {
2295                let host = lua
2296                    .create_table()
2297                    .map_err(|e| format!("create host table: {e}"))?;
2298                for (name, bridge) in bridges.iter() {
2299                    let bridge = bridge.clone();
2300                    let bname = name.clone();
2301                    let f = lua
2302                        .create_function(move |lua, arg: mlua::Value| {
2303                            let json_arg: serde_json::Value = lua.from_value(arg).map_err(|e| {
2304                                mlua::Error::external(format!("bridge {bname} arg → json: {e}"))
2305                            })?;
2306                            let result_json =
2307                                bridge.call(json_arg).map_err(mlua::Error::external)?;
2308                            lua.to_value(&result_json).map_err(|e| {
2309                                mlua::Error::external(format!("bridge {bname} ret → lua: {e}"))
2310                            })
2311                        })
2312                        .map_err(|e| format!("create_function {name}: {e}"))?;
2313                    host.set(name.as_str(), f)
2314                        .map_err(|e| format!("host.{name} set: {e}"))?;
2315                }
2316                g.set("host", host).map_err(|e| format!("set host: {e}"))?;
2317            }
2318
2319            // 4. eval
2320            let result: mlua::Value = lua
2321                .load(&source.source)
2322                .set_name(&source.label)
2323                .eval()
2324                .map_err(|e| format!("lua eval [{}]: {e}", source.label))?;
2325
2326            // 5. Marshal: shape `{ value=..., ok=true }` or raw value.
2327            let json_result: serde_json::Value = lua
2328                .from_value(result)
2329                .map_err(|e| format!("lua → json [{}]: {e}", source.label))?;
2330
2331            let (value, ok) = match &json_result {
2332                serde_json::Value::Object(map)
2333                    if map.contains_key("value") || map.contains_key("ok") =>
2334                {
2335                    let ok = map.get("ok").and_then(|v| v.as_bool()).unwrap_or(true);
2336                    let value = map.get("value").cloned().unwrap_or(json_result.clone());
2337                    (value, ok)
2338                }
2339                _ => (json_result, true),
2340            };
2341            Ok((value, ok))
2342        })
2343        .await
2344        .map_err(|e| WorkerError::Failed(format!("spawn_blocking join [{label}]: {e}")))?
2345        .map_err(WorkerError::Failed)?;
2346
2347    Ok(crate::worker::adapter::WorkerResult {
2348        value: outcome.0,
2349        ok: outcome.1,
2350        stats: None,
2351    }
2352    .ensure_worker_kind("lua"))
2353}
2354
2355impl Default for LuaInProcessSpawnerFactory {
2356    fn default() -> Self {
2357        Self::new()
2358    }
2359}
2360
2361impl SpawnerFactoryKind for LuaInProcessSpawnerFactory {
2362    const KIND: AgentKind = AgentKind::Lua;
2363    type Worker = LuaWorker;
2364}
2365
2366impl SpawnerFactory for LuaInProcessSpawnerFactory {
2367    fn build(
2368        &self,
2369        agent_def: &AgentDef,
2370        _hint: Option<&Value>,
2371    ) -> Result<Arc<dyn SpawnerAdapter>, CompileError> {
2372        // Inline `spec.source` (a Lua chunk carried by the BP itself) takes
2373        // precedence over `spec.fn_id`. This is the path a BP author uses to
2374        // ship a deterministic Lua gate without pre-registering it with the
2375        // factory — the plumbing (`run_lua_worker` / `LuaScriptSource`) is
2376        // the same, only the entry point differs.
2377        if let Some(source) = agent_def.spec.get("source").and_then(|v| v.as_str()) {
2378            let label = agent_def
2379                .spec
2380                .get("label")
2381                .and_then(|v| v.as_str())
2382                .map(str::to_string)
2383                .unwrap_or_else(|| format!("{}.lua", agent_def.name));
2384            let script = Arc::new(LuaScriptSource::new(source.to_string(), label));
2385            let bridges = Arc::new(self.bridges.clone());
2386            let wrapped: WorkerFn = Arc::new(move |inv| {
2387                let source = script.clone();
2388                let bridges = bridges.clone();
2389                Box::pin(run_lua_worker(source, bridges, inv))
2390            });
2391            let mut sp: InProcSpawner<LuaWorker> = InProcSpawner::<LuaWorker>::typed();
2392            sp.registry.insert(agent_def.name.to_string(), wrapped);
2393            return Ok(Arc::new(sp));
2394        }
2395        build_inproc_from_registry::<LuaWorker>(&self.registry, agent_def, "lua")
2396    }
2397}
2398
2399/// Factory for `AgentKind::RustFn`. At `build` time it looks the `fn_id`
2400/// up in its internal registry and returns an [`InProcSpawner`] with the
2401/// Rust closure `WorkerFn` registered under `agent_name`.
2402///
2403/// Naming convention: `<WorkerIMPL><AdapterType>SpawnerFactory` (RustFn
2404/// worker on InProcess adapter). Sibling to
2405/// [`LuaInProcessSpawnerFactory`] — the Lua-worker half of the same
2406/// split.
2407///
2408/// Spec shape:
2409/// ```jsonc
2410/// { "fn_id": "echo" }     // Rust closure id pre-registered with the factory
2411/// ```
2412pub struct RustFnInProcessSpawnerFactory {
2413    registry: HashMap<String, WorkerFn>,
2414}
2415
2416impl RustFnInProcessSpawnerFactory {
2417    /// Start with no registered closures.
2418    pub fn new() -> Self {
2419        Self {
2420            registry: HashMap::new(),
2421        }
2422    }
2423
2424    /// Register a Rust closure `WorkerFn` under `fn_id`, wrapping it so
2425    /// it matches the `WorkerFn` signature (boxed, pinned future).
2426    pub fn register_fn<F, Fut>(mut self, fn_id: impl Into<String>, f: F) -> Self
2427    where
2428        F: Fn(crate::worker::adapter::WorkerInvocation) -> Fut + Send + Sync + 'static,
2429        Fut: std::future::Future<
2430                Output = Result<
2431                    crate::worker::adapter::WorkerResult,
2432                    crate::worker::adapter::WorkerError,
2433                >,
2434            > + Send
2435            + 'static,
2436    {
2437        let f = Arc::new(f);
2438        let wrapped: WorkerFn = Arc::new(move |inv| {
2439            let f = f.clone();
2440            Box::pin(f(inv))
2441        });
2442        self.registry.insert(fn_id.into(), wrapped);
2443        self
2444    }
2445}
2446
2447impl Default for RustFnInProcessSpawnerFactory {
2448    fn default() -> Self {
2449        Self::new()
2450    }
2451}
2452
2453impl SpawnerFactoryKind for RustFnInProcessSpawnerFactory {
2454    const KIND: AgentKind = AgentKind::RustFn;
2455    type Worker = RustFnWorker;
2456}
2457
2458impl SpawnerFactory for RustFnInProcessSpawnerFactory {
2459    fn build(
2460        &self,
2461        agent_def: &AgentDef,
2462        _hint: Option<&Value>,
2463    ) -> Result<Arc<dyn SpawnerAdapter>, CompileError> {
2464        build_inproc_from_registry::<RustFnWorker>(&self.registry, agent_def, "rust_fn")
2465    }
2466}
2467
2468/// Shared build helper used by both the Lua and the RustFn factories —
2469/// look `spec.fn_id` up in the registry and return an `InProcSpawner`.
2470/// The generic type parameter `W` fixes the per-kind Worker concrete
2471/// type at the type level (the build-site half of the trait's
2472/// associated-type binding across the four-layer cascade).
2473fn build_inproc_from_registry<W>(
2474    registry: &HashMap<String, WorkerFn>,
2475    agent_def: &AgentDef,
2476    kind_label: &str,
2477) -> Result<Arc<dyn SpawnerAdapter>, CompileError>
2478where
2479    W: crate::worker::Worker + From<crate::worker::WorkerJoinHandler> + Send + Sync + 'static,
2480{
2481    let agent_name = &agent_def.name;
2482    let spec = &agent_def.spec;
2483    let invalid = |msg: String| CompileError::InvalidSpec {
2484        name: agent_name.to_string(),
2485        msg,
2486    };
2487    let fn_id = spec
2488        .get("fn_id")
2489        .and_then(|v| v.as_str())
2490        .ok_or_else(|| invalid(format!("{kind_label} spec: 'fn_id' (string) required")))?;
2491    let f = registry
2492        .get(fn_id)
2493        .cloned()
2494        .ok_or_else(|| invalid(format!("fn_id '{fn_id}' not registered in factory")))?;
2495    let mut sp: InProcSpawner<W> = InProcSpawner::<W>::typed();
2496    // Register under `agent_name` (the flow's `Step.ref`). Both
2497    // `CompiledAgentTable` and the `InProcSpawner` look the function up
2498    // by name, so the same key is needed at both layers.
2499    sp.registry.insert(agent_name.to_string(), f);
2500    Ok(Arc::new(sp))
2501}
2502
2503/// Concrete Worker type for the Lua kind — a handle to a Lua-eval task
2504/// inside an mlua VM. Embeds a `WorkerJoinHandler`. Reserved as the home
2505/// for future Lua-specific extensions (an mlua VM cancellation
2506/// mechanism, Lua-side error type retention, and so on).
2507pub struct LuaWorker {
2508    /// The join handle / cancellation token for the underlying task.
2509    pub handler: crate::worker::WorkerJoinHandler,
2510}
2511
2512impl From<crate::worker::WorkerJoinHandler> for LuaWorker {
2513    fn from(handler: crate::worker::WorkerJoinHandler) -> Self {
2514        Self { handler }
2515    }
2516}
2517
2518#[async_trait::async_trait]
2519impl crate::worker::Worker for LuaWorker {
2520    fn id(&self) -> &crate::types::WorkerId {
2521        &self.handler.worker_id
2522    }
2523    fn cancel_token(&self) -> tokio_util::sync::CancellationToken {
2524        self.handler.cancel.clone()
2525    }
2526    async fn join(self: Box<Self>) -> Result<(), crate::worker::adapter::WorkerError> {
2527        self.handler.await_completion().await
2528    }
2529}
2530
2531/// Concrete Worker type for the RustFn kind — a handle to a task that
2532/// directly calls a Rust closure. Embeds a `WorkerJoinHandler`. Being a
2533/// pure function, there is minimal kind-specific extension surface here;
2534/// the primary purpose is to nail down the type binding.
2535pub struct RustFnWorker {
2536    /// The join handle / cancellation token for the underlying task.
2537    pub handler: crate::worker::WorkerJoinHandler,
2538}
2539
2540impl From<crate::worker::WorkerJoinHandler> for RustFnWorker {
2541    fn from(handler: crate::worker::WorkerJoinHandler) -> Self {
2542        Self { handler }
2543    }
2544}
2545
2546#[async_trait::async_trait]
2547impl crate::worker::Worker for RustFnWorker {
2548    fn id(&self) -> &crate::types::WorkerId {
2549        &self.handler.worker_id
2550    }
2551    fn cancel_token(&self) -> tokio_util::sync::CancellationToken {
2552        self.handler.cancel.clone()
2553    }
2554    async fn join(self: Box<Self>) -> Result<(), crate::worker::adapter::WorkerError> {
2555        self.handler.await_completion().await
2556    }
2557}
2558
2559/// Factory for `AgentKind::Operator`. Looks up the `Arc<dyn Operator>`
2560/// pre-registered under `spec.operator_ref` and wraps it in an
2561/// `OperatorSpawner`. Also resolves `AgentDef.profile.worker_binding` into
2562/// a `WorkerBinding` at compile time and fails loud (`CompileError::InvalidSpec`)
2563/// when the resolved operator's `Operator::requires_worker_binding` is `true`
2564/// and no binding was declared.
2565///
2566/// Spec shape:
2567/// ```jsonc
2568/// { "operator_ref": "main_ai" }     // Operator id pre-registered with the factory
2569/// ```
2570///
2571/// # The only axis that reaches an Operator
2572///
2573/// This factory (`OperatorSpawnerFactory` → `OperatorSpawner`) is the
2574/// **AgentSpec axis**: a `kind = Operator` `AgentDef` names its seat
2575/// through `spec.operator_ref`, and at `compile()` time an
2576/// `Arc<dyn Operator>` for that seat is placed in `routes[agent_name]`.
2577/// Because the `agent.md` loader (`agent_md_loader`) defaults `kind` to
2578/// `Operator`, agents that flow in through external agent.md files land
2579/// here.
2580///
2581/// There was a second axis until recently: `OperatorDelegateMiddleware`,
2582/// opted into with `spawner_hints.layers = ["operator_delegate"]`, which
2583/// ignored `ctx.agent` and handed every spawn in the session to one
2584/// backend named at launch. When both were effective it sat at the outer
2585/// end of the stack and bypassed `inner.spawn` entirely, leaving this
2586/// factory's routes entry inert — so the two axes needed an exclusivity
2587/// story, and an author needed to know which one a given Blueprint was
2588/// actually running on.
2589///
2590/// That axis was removed (it resolved its destination from the launch
2591/// record rather than the Run's seat, so a handover could not move it,
2592/// and it had no per-agent spawner with which to render an agent's
2593/// `system_prompt`). Declaring its key is now a
2594/// [`CompileError::RemovedSpawnerHint`]. One axis remains, so there is no
2595/// exclusivity rule left to get wrong: if a dispatch reaches an
2596/// `Operator`, it reached it through the agent's declared seat.
2597///
2598/// # Who answers `spec.operator_ref` — resolver first, registry second
2599///
2600/// `spec.operator_ref` names a Blueprint-declared **seat**
2601/// (`Blueprint.operators[]`), and a seat's holder is per-Run, mutable
2602/// state. Two ways to answer it:
2603///
2604/// - **A [`OperatorSlotResolver`] installed via
2605///   [`Self::set_slot_resolver`]** — the host hands back an indirection
2606///   that resolves the seat's *current* holder on every dispatch
2607///   (`mlua-swarm-server`'s `AssigneeRouter`). What this factory bakes into
2608///   `routes[agent_name]` is then "the seat", not "the session that held it
2609///   when the Blueprint compiled" — model §4.3 **A10**. This is the wiring
2610///   `mse serve` uses.
2611/// - **This factory's own `id → Arc<dyn Operator>` map** (no resolver
2612///   installed) — the direct binding used by hosts with no Run store to
2613///   resolve holders against: `mse bp doctor`'s lint stubs, in-process
2614///   embeddings, tests.
2615///
2616/// The two never mix within one build: an installed resolver that cannot
2617/// serve a seat fails the compile rather than falling through to the map,
2618/// because falling through is exactly how a dispatch ends up at a backend
2619/// the caller never named.
2620///
2621/// Interior mutability is provided by `Arc<RwLock>`s. Even after the
2622/// factory has been stored as `Arc<dyn SpawnerFactory>` in
2623/// `SpawnerRegistry`, a caller holding an `Arc` clone can still add
2624/// Operator backends dynamically via `register_operator(&self, id, op)` or
2625/// install the resolver. `build()` performs a `read()` lookup each time.
2626pub struct OperatorSpawnerFactory {
2627    operators: Arc<std::sync::RwLock<HashMap<String, Arc<dyn Operator>>>>,
2628    /// Installed by the host at wiring time; see the struct doc. `None`
2629    /// keeps the historical registry-lookup behaviour.
2630    slot_resolver: Arc<std::sync::RwLock<Option<Arc<dyn OperatorSlotResolver>>>>,
2631}
2632
2633impl OperatorSpawnerFactory {
2634    /// Start with no registered Operator backends and no slot resolver.
2635    pub fn new() -> Self {
2636        Self {
2637            operators: Arc::new(std::sync::RwLock::new(HashMap::new())),
2638            slot_resolver: Arc::new(std::sync::RwLock::new(None)),
2639        }
2640    }
2641
2642    /// Install the [`OperatorSlotResolver`] every `kind = Operator` agent's
2643    /// `spec.operator_ref` is answered through from now on (see the struct
2644    /// doc). Installing replaces any previous resolver.
2645    ///
2646    /// Takes `&self` on purpose: the host builds its factory before it has
2647    /// a `RunStore` to resolve holders against (the router builder resolves
2648    /// the store), and the same `Arc` is already inside a `SpawnerRegistry`
2649    /// by then.
2650    pub fn set_slot_resolver(&self, resolver: Arc<dyn OperatorSlotResolver>) -> &Self {
2651        *self
2652            .slot_resolver
2653            .write()
2654            .expect("OperatorSpawnerFactory.slot_resolver RwLock poisoned") = Some(resolver);
2655        self
2656    }
2657
2658    /// The `Arc<dyn Operator>` a `kind = Operator` agent declaring
2659    /// `operator_ref = slot` dispatches through — the single lookup
2660    /// [`SpawnerFactory::build`] performs, exposed so a host can assert
2661    /// what its wiring resolves to without standing up a compile.
2662    ///
2663    /// `agent` only shapes the error message (`CompileError::InvalidSpec`
2664    /// is keyed by agent name).
2665    pub fn resolve_operator(
2666        &self,
2667        slot: &str,
2668        agent: &str,
2669    ) -> Result<Arc<dyn Operator>, CompileError> {
2670        let invalid = |msg: String| CompileError::InvalidSpec {
2671            name: agent.to_string(),
2672            msg,
2673        };
2674        let resolver = self
2675            .slot_resolver
2676            .read()
2677            .expect("OperatorSpawnerFactory.slot_resolver RwLock poisoned")
2678            .clone();
2679        if let Some(resolver) = resolver {
2680            return resolver.resolve(slot).ok_or_else(|| {
2681                invalid(format!(
2682                    "operator_ref '{slot}': the installed OperatorSlotResolver serves no such \
2683                     Operator seat. The seat is declared by Blueprint.operators[]; nothing is \
2684                     resolved from the factory's own registry here, because falling back to it \
2685                     would dispatch this agent to a backend the seat does not name."
2686                ))
2687            });
2688        }
2689        let operators = self
2690            .operators
2691            .read()
2692            .expect("OperatorSpawnerFactory.operators RwLock poisoned");
2693        operators.get(slot).cloned().ok_or_else(|| {
2694            let mut names: Vec<String> = operators.keys().cloned().collect();
2695            names.sort();
2696            let names_list = if names.is_empty() {
2697                "<none>".to_string()
2698            } else {
2699                names.join(", ")
2700            };
2701            invalid(format!(
2702                "operator_ref '{slot}' not registered in factory. \
2703                 Registered sids: [{names_list}]. \
2704                 Hint: call mse_operator_join(roles=[...]) to mint the sid first."
2705            ))
2706        })
2707    }
2708
2709    /// Register an Operator backend dynamically through `&self`.
2710    /// Overwrites are allowed — later wins. Callers can still reach this
2711    /// after the factory has been stored as `Arc<dyn SpawnerFactory>` in
2712    /// `SpawnerRegistry`, as long as they hold an `Arc` clone; interior
2713    /// mutability is provided by the inner `RwLock`.
2714    pub fn register_operator(&self, id: impl Into<String>, op: Arc<dyn Operator>) -> &Self {
2715        self.operators
2716            .write()
2717            .expect("OperatorSpawnerFactory.operators RwLock poisoned")
2718            .insert(id.into(), op);
2719        self
2720    }
2721
2722    /// Dynamically unregister an id (used to clean up when a WebSocket
2723    /// disconnects, for example). A missing id is a no-op.
2724    pub fn unregister_operator(&self, id: &str) -> &Self {
2725        self.operators
2726            .write()
2727            .expect("OperatorSpawnerFactory.operators RwLock poisoned")
2728            .remove(id);
2729        self
2730    }
2731}
2732
2733impl Default for OperatorSpawnerFactory {
2734    fn default() -> Self {
2735        Self::new()
2736    }
2737}
2738
2739impl SpawnerFactoryKind for OperatorSpawnerFactory {
2740    const KIND: AgentKind = AgentKind::Operator;
2741    type Worker = crate::operator::OperatorWorker;
2742}
2743
2744impl SpawnerFactory for OperatorSpawnerFactory {
2745    /// No build hint is read here: an Operator agent's whole input is its
2746    /// declared seat (`spec.operator_ref`) plus its profile. The hint slot
2747    /// used to carry a launch-scoped session pin, which was the compile
2748    /// baking a destination — see the struct doc.
2749    fn build(
2750        &self,
2751        agent_def: &AgentDef,
2752        _hint: Option<&Value>,
2753    ) -> Result<Arc<dyn SpawnerAdapter>, CompileError> {
2754        let agent_name = &agent_def.name;
2755        let spec = &agent_def.spec;
2756        // Bake AgentDef.profile.system_prompt into the OperatorSpawner at compile time.
2757        // `Some` → adopted first at spawn time; `None` → falls back to fetch_prompt (initial_directive).
2758        // Fallback path. Sibling: AgentBlockInProcessSpawnerFactory
2759        // (agent_block/runtime.rs) does the same compile-time bake by stuffing
2760        // the profile into BlockConfig.context.
2761        let system_prompt = agent_def.profile.as_ref().map(|p| p.system_prompt.clone());
2762        let invalid = |msg: String| CompileError::InvalidSpec {
2763            name: agent_name.to_string(),
2764            msg,
2765        };
2766        let op_ref = spec
2767            .get("operator_ref")
2768            .and_then(|v| v.as_str())
2769            .ok_or_else(|| invalid("operator spec: 'operator_ref' (string) required".into()))?;
2770        // The seat, not the holder: with a resolver installed this hands
2771        // back the per-dispatch indirection, and without one it is the
2772        // historical direct registry binding. See the struct doc.
2773        let op = self.resolve_operator(op_ref, agent_name)?;
2774
2775        // Resolve the Blueprint-baked worker binding from
2776        // `AgentDef.profile.worker_binding` — the SoT for the
2777        // declaration↔executor binding (see `WorkerBinding` doc). Fail
2778        // loud at compile time when the operator backend requires one
2779        // and the Blueprint didn't declare it; this is a compile-time
2780        // gate, not a runtime guess.
2781        let worker_binding = agent_def
2782            .profile
2783            .as_ref()
2784            .and_then(|p| p.worker_binding.as_ref())
2785            .map(|variant| WorkerBinding {
2786                variant: variant.clone(),
2787                tools: agent_def
2788                    .profile
2789                    .as_ref()
2790                    .map(|p| p.tools.clone())
2791                    .unwrap_or_default(),
2792                // Compile-time path: no immutable BoundAgent snapshot exists
2793                // here (the launch path resolves the digest). Self-check
2794                // inputs are supplied on the launch axis only.
2795                request_digest: None,
2796                requested_model: None,
2797            });
2798        if op.requires_worker_binding() && worker_binding.is_none() {
2799            // Issue #9: the two Blueprint authoring paths (direct JSON
2800            // and `$agent_md` file ref) both land here. Old message
2801            // pointed only at the `.md` frontmatter, which was
2802            // confusing for authors on the JSON-direct path. The prefix
2803            // const keeps this message and the GH #79 Diagnostic
2804            // specialization in lockstep.
2805            return Err(invalid(format!(
2806                "{WORKER_BINDING_REQUIRED_MSG_PREFIX}. \
2807                 Fix by either: \
2808                 (a) if authoring the Blueprint JSON directly, add \
2809                 `agents[N].profile.worker_binding: \"<subagent-type>\"` \
2810                 to the JSON literal; or \
2811                 (b) if using an $agent_md file ref, add \
2812                 `worker_binding: <subagent-type>` to the agent .md frontmatter."
2813            )));
2814        }
2815        Ok(Arc::new(OperatorSpawner::new(
2816            op,
2817            system_prompt,
2818            worker_binding,
2819        )))
2820    }
2821}
2822
2823#[cfg(test)]
2824mod operator_spawner_factory_worker_binding_tests {
2825    use super::*;
2826    use crate::blueprint::AgentProfile;
2827    use crate::core::ctx::Ctx;
2828    use crate::types::CapToken;
2829    use crate::worker::adapter::{WorkerError, WorkerResult};
2830
2831    /// Minimal `Operator` stub whose `requires_worker_binding` is
2832    /// configurable — enough to exercise the compile-time fail-loud gate
2833    /// without standing up a real backend (e.g. `WSOperatorSession`,
2834    /// which lives in a downstream crate).
2835    struct StubOperator {
2836        requires_binding: bool,
2837    }
2838
2839    #[async_trait]
2840    impl Operator for StubOperator {
2841        async fn execute(
2842            &self,
2843            _ctx: &Ctx,
2844            _system: Option<String>,
2845            _prompt: Value,
2846            _worker: Option<WorkerBinding>,
2847            _worker_token: CapToken,
2848        ) -> Result<WorkerResult, WorkerError> {
2849            Ok(WorkerResult {
2850                value: Value::Null,
2851                ok: true,
2852                stats: None,
2853            })
2854        }
2855
2856        fn requires_worker_binding(&self) -> bool {
2857            self.requires_binding
2858        }
2859    }
2860
2861    fn agent_def_with(profile: Option<AgentProfile>) -> AgentDef {
2862        AgentDef {
2863            name: "test-agent".to_string(),
2864            kind: AgentKind::Operator,
2865            spec: serde_json::json!({ "operator_ref": "op1" }),
2866            profile,
2867            meta: None,
2868            runner: None,
2869            runner_ref: None,
2870            verdict: None,
2871            lints: None,
2872        }
2873    }
2874
2875    #[test]
2876    fn build_fails_loud_when_binding_required_but_absent() {
2877        let factory = OperatorSpawnerFactory::new();
2878        factory.register_operator(
2879            "op1",
2880            Arc::new(StubOperator {
2881                requires_binding: true,
2882            }) as Arc<dyn Operator>,
2883        );
2884        let def = agent_def_with(Some(AgentProfile::default()));
2885        match factory.build(&def, None) {
2886            Err(CompileError::InvalidSpec { name, msg }) => {
2887                assert_eq!(name, "test-agent");
2888                assert!(
2889                    msg.contains("worker_binding is required"),
2890                    "unexpected message: {msg}"
2891                );
2892                // Issue #9: the message must be actionable for both
2893                // authoring paths — the JSON-direct hint and the
2894                // $agent_md hint both surface.
2895                assert!(
2896                    msg.contains("agents[N].profile.worker_binding"),
2897                    "message missing JSON-direct hint (issue #9): {msg}"
2898                );
2899                assert!(
2900                    msg.contains("agent .md frontmatter"),
2901                    "message missing $agent_md hint: {msg}"
2902                );
2903            }
2904            Err(other) => panic!("expected InvalidSpec, got: {other:?}"),
2905            Ok(_) => panic!("expected compile-time failure, got Ok"),
2906        }
2907    }
2908
2909    /// GH #79 regression lock: the factory error the compile-time gate
2910    /// emits must keep starting with the shared
2911    /// `WORKER_BINDING_REQUIRED_MSG_PREFIX` — otherwise the
2912    /// `From<&CompileError>` Diagnostic specialization (and `bp_doctor`'s
2913    /// dual-stage `worker-binding-missing` story) silently degrades to
2914    /// the generic `invalid-agent-spec` kind.
2915    #[test]
2916    fn factory_error_message_carries_the_shared_prefix_and_specializes_the_diagnostic() {
2917        let factory = OperatorSpawnerFactory::new();
2918        factory.register_operator(
2919            "op1",
2920            Arc::new(StubOperator {
2921                requires_binding: true,
2922            }) as Arc<dyn Operator>,
2923        );
2924        let def = agent_def_with(Some(AgentProfile::default()));
2925        let err = match factory.build(&def, None) {
2926            Err(err) => err,
2927            Ok(_) => panic!("expected compile-time failure, got Ok"),
2928        };
2929        match &err {
2930            CompileError::InvalidSpec { msg, .. } => {
2931                assert!(
2932                    msg.starts_with(WORKER_BINDING_REQUIRED_MSG_PREFIX),
2933                    "factory message must start with the shared prefix, got: {msg}"
2934                );
2935            }
2936            other => panic!("expected InvalidSpec, got: {other:?}"),
2937        }
2938        let d = mlua_swarm_diag::Diagnostic::from(&err);
2939        assert_eq!(d.kind, "worker-binding-missing");
2940    }
2941
2942    #[test]
2943    fn build_succeeds_when_binding_required_and_present() {
2944        let factory = OperatorSpawnerFactory::new();
2945        factory.register_operator(
2946            "op1",
2947            Arc::new(StubOperator {
2948                requires_binding: true,
2949            }) as Arc<dyn Operator>,
2950        );
2951        let profile = AgentProfile {
2952            worker_binding: Some("code-worker".to_string()),
2953            tools: vec!["Read".to_string(), "Edit".to_string()],
2954            ..Default::default()
2955        };
2956        let def = agent_def_with(Some(profile));
2957        assert!(
2958            factory.build(&def, None).is_ok(),
2959            "expected Ok when worker_binding is declared"
2960        );
2961    }
2962
2963    #[test]
2964    fn build_succeeds_when_binding_not_required_and_absent() {
2965        let factory = OperatorSpawnerFactory::new();
2966        factory.register_operator(
2967            "op1",
2968            Arc::new(StubOperator {
2969                requires_binding: false,
2970            }) as Arc<dyn Operator>,
2971        );
2972        let def = agent_def_with(Some(AgentProfile::default()));
2973        assert!(
2974            factory.build(&def, None).is_ok(),
2975            "backends that don't require a binding must not be gated by its absence"
2976        );
2977    }
2978}
2979
2980// ─── LuaInProcessSpawnerFactory: inline `spec.source` support ─────────────
2981//
2982// Issue `ab3d1145`: BPs served by `mse serve` couldn't declare `kind: lua`
2983// without pre-registering a `fn_id` on the factory. These tests cover the
2984// new inline path — `spec.source = "<lua chunk>"` (optionally with `label`)
2985// wraps a fresh `LuaScriptSource` at `build` time and runs it through the
2986// same `run_lua_worker` plumbing as the registry path.
2987#[cfg(test)]
2988mod lua_inline_source_tests {
2989    use super::*;
2990    use crate::types::{CapToken, Role, StepId};
2991
2992    fn agent(name: &str, spec: Value) -> AgentDef {
2993        AgentDef {
2994            name: name.to_string(),
2995            kind: AgentKind::Lua,
2996            spec,
2997            profile: None,
2998            meta: None,
2999            runner: None,
3000            runner_ref: None,
3001            verdict: None,
3002            lints: None,
3003        }
3004    }
3005
3006    fn test_invocation(prompt: &str) -> crate::worker::adapter::WorkerInvocation {
3007        crate::worker::adapter::WorkerInvocation::new(
3008            CapToken {
3009                agent_id: "a".into(),
3010                role: Role::Worker,
3011                scopes: vec!["*".into()],
3012                issued_at: 0,
3013                expire_at: u64::MAX / 2,
3014                max_uses: None,
3015                nonce: "test-nonce".into(),
3016                sig_hex: "".into(),
3017            },
3018            StepId::parse("ST-test").expect("StepId parse"),
3019            1,
3020            "g",
3021            prompt,
3022        )
3023    }
3024
3025    #[test]
3026    fn build_accepts_inline_source_without_pre_registration() {
3027        let factory = LuaInProcessSpawnerFactory::new();
3028        let def = agent(
3029            "g",
3030            serde_json::json!({ "source": "return { value = 42, ok = true }" }),
3031        );
3032        assert!(
3033            factory.build(&def, None).is_ok(),
3034            "inline spec.source must build without a pre-registered fn_id"
3035        );
3036    }
3037
3038    #[test]
3039    fn build_rejects_when_neither_source_nor_fn_id_is_present() {
3040        let factory = LuaInProcessSpawnerFactory::new();
3041        let def = agent("g", serde_json::json!({}));
3042        match factory.build(&def, None) {
3043            Err(CompileError::InvalidSpec { msg, .. }) => {
3044                assert!(
3045                    msg.contains("fn_id"),
3046                    "empty spec must still surface the fn_id-required message: {msg}"
3047                );
3048            }
3049            Err(other) => panic!("expected InvalidSpec, got a different CompileError: {other}"),
3050            // `SpawnerAdapter` is not Debug, so we can't `unwrap_err()` /
3051            // pattern-print the Ok arm — describe the mismatch directly.
3052            Ok(_) => panic!("expected InvalidSpec, got Ok(SpawnerAdapter)"),
3053        }
3054    }
3055
3056    /// The inline path shares `run_lua_worker` with the registry path, so
3057    /// exercising the marshaller once through it is enough to prove the
3058    /// wrap is faithful.
3059    #[tokio::test]
3060    async fn inline_source_evaluates_and_marshals_result() {
3061        let source =
3062            LuaScriptSource::new("return { value = _PROMPT .. '!', ok = true }", "smoke.lua");
3063        let out = run_lua_worker(
3064            std::sync::Arc::new(source),
3065            std::sync::Arc::new(HashMap::new()),
3066            test_invocation("hello"),
3067        )
3068        .await
3069        .expect("lua worker ok");
3070        assert_eq!(out.value, serde_json::json!("hello!"));
3071        assert!(out.ok);
3072    }
3073
3074    #[tokio::test]
3075    async fn inline_source_can_signal_agent_level_failure() {
3076        // Deterministic gate pattern: return `ok = false` to flip the
3077        // dispatch outcome to `Blocked` (the flow.ir Try catch path).
3078        let source = LuaScriptSource::new("return { value = 'nope', ok = false }", "gate.lua");
3079        let out = run_lua_worker(
3080            std::sync::Arc::new(source),
3081            std::sync::Arc::new(HashMap::new()),
3082            test_invocation("input"),
3083        )
3084        .await
3085        .expect("lua worker ok");
3086        assert_eq!(out.value, serde_json::json!("nope"));
3087        assert!(!out.ok);
3088    }
3089}
3090
3091// ─── GH #21 Phase 2: `Blueprint.metas` / `AgentMeta.meta_ref` / static
3092// `$step_meta.ref` compile-time validation ─────────────────────────────────
3093#[cfg(test)]
3094mod meta_ref_validation_tests {
3095    use super::*;
3096    use crate::blueprint::{AgentMeta, MetaDef};
3097    use crate::worker::adapter::WorkerResult;
3098
3099    fn registry_with_echo() -> SpawnerRegistry {
3100        let factory = RustFnInProcessSpawnerFactory::new().register_fn("echo", |inv| async move {
3101            Ok(WorkerResult {
3102                value: Value::String(inv.prompt),
3103                ok: true,
3104                stats: None,
3105            })
3106        });
3107        let mut reg = SpawnerRegistry::new();
3108        reg.register::<RustFnInProcessSpawnerFactory>(Arc::new(factory));
3109        reg
3110    }
3111
3112    fn rustfn_agent(name: &str) -> AgentDef {
3113        AgentDef {
3114            name: name.to_string(),
3115            kind: AgentKind::RustFn,
3116            spec: serde_json::json!({ "fn_id": "echo" }),
3117            profile: None,
3118            meta: None,
3119            runner: None,
3120            runner_ref: None,
3121            verdict: None,
3122            lints: None,
3123        }
3124    }
3125
3126    fn simple_flow(agent_ref: &str, in_: Expr) -> FlowNode {
3127        FlowNode::Step {
3128            ref_: agent_ref.to_string(),
3129            in_,
3130            out: Expr::Path {
3131                at: "$.output".parse().expect("literal test path: $.output"),
3132            },
3133        }
3134    }
3135
3136    fn minimal_bp(agents: Vec<AgentDef>, metas: Vec<MetaDef>, flow: FlowNode) -> Blueprint {
3137        Blueprint {
3138            schema_version: crate::blueprint::current_schema_version(),
3139            id: "meta-ref-ut".into(),
3140            flow,
3141            agents,
3142            operators: vec![],
3143            metas,
3144            hints: Default::default(),
3145            strategy: Default::default(),
3146            metadata: BlueprintMetadata::default(),
3147            spawner_hints: Default::default(),
3148            default_agent_kind: AgentKind::Operator,
3149            default_operator_kind: None,
3150            default_init_ctx: None,
3151            default_agent_ctx: None,
3152            default_context_policy: None,
3153            projection_placement: None,
3154            audits: vec![],
3155            degradation_policy: None,
3156            runners: vec![],
3157            default_runner: None,
3158            subprocesses: vec![],
3159            check_policy: None,
3160            blueprint_ref_includes: Vec::new(),
3161        }
3162    }
3163
3164    #[test]
3165    fn valid_meta_ref_compiles() {
3166        let mut agent = rustfn_agent("worker");
3167        agent.meta = Some(AgentMeta {
3168            meta_ref: Some("shared".to_string()),
3169            ..Default::default()
3170        });
3171        let bp = minimal_bp(
3172            vec![agent],
3173            vec![MetaDef {
3174                name: "shared".into(),
3175                ctx: serde_json::json!({ "k": "v" }),
3176            }],
3177            simple_flow(
3178                "worker",
3179                Expr::Path {
3180                    at: "$.input".parse().expect("literal test path: $.input"),
3181                },
3182            ),
3183        );
3184        let compiler = Compiler::new(registry_with_echo());
3185        assert!(
3186            compiler.compile(&bp).is_ok(),
3187            "a resolvable AgentMeta.meta_ref must compile"
3188        );
3189    }
3190
3191    #[test]
3192    fn unknown_agent_meta_ref_is_unresolved_meta_ref() {
3193        let mut agent = rustfn_agent("worker");
3194        agent.meta = Some(AgentMeta {
3195            meta_ref: Some("missing".to_string()),
3196            ..Default::default()
3197        });
3198        let bp = minimal_bp(
3199            vec![agent],
3200            vec![],
3201            simple_flow(
3202                "worker",
3203                Expr::Path {
3204                    at: "$.input".parse().expect("literal test path: $.input"),
3205                },
3206            ),
3207        );
3208        let compiler = Compiler::new(registry_with_echo());
3209        match compiler.compile(&bp) {
3210            Err(CompileError::UnresolvedMetaRef {
3211                where_,
3212                meta_ref,
3213                defined,
3214            }) => {
3215                assert!(
3216                    where_.contains("worker"),
3217                    "where_ must name the agent: {where_}"
3218                );
3219                assert_eq!(meta_ref, "missing");
3220                assert!(defined.is_empty());
3221            }
3222            Err(other) => {
3223                panic!("expected UnresolvedMetaRef, got a different CompileError: {other}")
3224            }
3225            Ok(_) => panic!("expected compile-time failure, got Ok"),
3226        }
3227    }
3228
3229    #[test]
3230    fn unknown_static_step_meta_ref_in_lit_is_unresolved_meta_ref() {
3231        let agent = rustfn_agent("worker");
3232        let in_ = Expr::Lit {
3233            value: serde_json::json!({ "$step_meta": { "ref": "missing" }, "$in": "go" }),
3234        };
3235        let bp = minimal_bp(vec![agent], vec![], simple_flow("worker", in_));
3236        let compiler = Compiler::new(registry_with_echo());
3237        match compiler.compile(&bp) {
3238            Err(CompileError::UnresolvedMetaRef {
3239                where_, meta_ref, ..
3240            }) => {
3241                assert!(
3242                    where_.contains("worker"),
3243                    "where_ must name the offending step: {where_}"
3244                );
3245                assert_eq!(meta_ref, "missing");
3246            }
3247            Err(other) => {
3248                panic!("expected UnresolvedMetaRef, got a different CompileError: {other}")
3249            }
3250            Ok(_) => panic!("expected compile-time failure, got Ok"),
3251        }
3252    }
3253
3254    #[test]
3255    fn path_op_input_with_no_static_envelope_compiles_fine() {
3256        let agent = rustfn_agent("worker");
3257        let bp = minimal_bp(
3258            vec![agent],
3259            vec![],
3260            simple_flow(
3261                "worker",
3262                Expr::Path {
3263                    at: "$.input".parse().expect("literal test path: $.input"),
3264                },
3265            ),
3266        );
3267        let compiler = Compiler::new(registry_with_echo());
3268        assert!(
3269            compiler.compile(&bp).is_ok(),
3270            "a non-Lit Step.in must not trigger the best-effort static $step_meta check"
3271        );
3272    }
3273}
3274
3275// ─── GH #34: `Blueprint.audits[].agent` compile-time validation ────────────
3276#[cfg(test)]
3277mod audit_agent_validation_tests {
3278    use super::*;
3279    use crate::worker::adapter::WorkerResult;
3280    use mlua_swarm_schema::{AuditDef, AuditMode};
3281
3282    fn registry_with_echo() -> SpawnerRegistry {
3283        let factory = RustFnInProcessSpawnerFactory::new().register_fn("echo", |inv| async move {
3284            Ok(WorkerResult {
3285                value: Value::String(inv.prompt),
3286                ok: true,
3287                stats: None,
3288            })
3289        });
3290        let mut reg = SpawnerRegistry::new();
3291        reg.register::<RustFnInProcessSpawnerFactory>(Arc::new(factory));
3292        reg
3293    }
3294
3295    fn rustfn_agent(name: &str) -> AgentDef {
3296        AgentDef {
3297            name: name.to_string(),
3298            kind: AgentKind::RustFn,
3299            spec: serde_json::json!({ "fn_id": "echo" }),
3300            profile: None,
3301            meta: None,
3302            runner: None,
3303            runner_ref: None,
3304            verdict: None,
3305            lints: None,
3306        }
3307    }
3308
3309    fn minimal_bp(agents: Vec<AgentDef>, audits: Vec<AuditDef>) -> Blueprint {
3310        Blueprint {
3311            schema_version: crate::blueprint::current_schema_version(),
3312            id: "audit-ref-ut".into(),
3313            flow: FlowNode::Step {
3314                ref_: "worker".to_string(),
3315                in_: Expr::Path {
3316                    at: "$.input".parse().expect("literal test path: $.input"),
3317                },
3318                out: Expr::Path {
3319                    at: "$.output".parse().expect("literal test path: $.output"),
3320                },
3321            },
3322            agents,
3323            operators: vec![],
3324            metas: vec![],
3325            hints: Default::default(),
3326            strategy: Default::default(),
3327            metadata: BlueprintMetadata::default(),
3328            spawner_hints: Default::default(),
3329            default_agent_kind: AgentKind::Operator,
3330            default_operator_kind: None,
3331            default_init_ctx: None,
3332            default_agent_ctx: None,
3333            default_context_policy: None,
3334            projection_placement: None,
3335            audits,
3336            degradation_policy: None,
3337            runners: vec![],
3338            default_runner: None,
3339            subprocesses: vec![],
3340            check_policy: None,
3341            blueprint_ref_includes: Vec::new(),
3342        }
3343    }
3344
3345    #[test]
3346    fn unresolved_audit_agent_is_a_loud_compile_error() {
3347        let bp = minimal_bp(
3348            vec![rustfn_agent("worker")],
3349            vec![AuditDef {
3350                agent: "missing-auditor".to_string(),
3351                steps: None,
3352                mode: AuditMode::default(),
3353            }],
3354        );
3355        let compiler = Compiler::new(registry_with_echo());
3356        match compiler.compile(&bp) {
3357            Err(CompileError::UnresolvedAuditAgent { agent, defined }) => {
3358                assert_eq!(agent, "missing-auditor");
3359                assert_eq!(defined, vec!["worker".to_string()]);
3360            }
3361            Err(other) => {
3362                panic!("expected UnresolvedAuditAgent, got a different CompileError: {other}")
3363            }
3364            Ok(_) => panic!("expected compile-time failure, got Ok"),
3365        }
3366    }
3367
3368    #[test]
3369    fn resolved_audit_agent_compiles_fine() {
3370        let bp = minimal_bp(
3371            vec![rustfn_agent("worker"), rustfn_agent("auditor")],
3372            vec![AuditDef {
3373                agent: "auditor".to_string(),
3374                steps: None,
3375                mode: AuditMode::default(),
3376            }],
3377        );
3378        let compiler = Compiler::new(registry_with_echo());
3379        assert!(
3380            compiler.compile(&bp).is_ok(),
3381            "an audits[].agent that names a declared AgentDef must compile"
3382        );
3383    }
3384}
3385
3386// ─── how `spec.operator_ref` is answered ──────────────────────────────────
3387//
3388// `spec.operator_ref` names a Blueprint-declared Operator seat. These tests
3389// cover both halves of resolving it: the compiler handing the factory
3390// nothing but the author's own hint (no synthesized launch pin — that used
3391// to bake a destination), and the factory answering the seat through an
3392// installed `OperatorSlotResolver` when there is one, or its own registry
3393// when there is not.
3394#[cfg(test)]
3395mod operator_ref_resolution_tests {
3396    use super::*;
3397    use crate::core::ctx::Ctx;
3398    use crate::types::CapToken;
3399    use crate::worker::adapter::{WorkerError, WorkerResult};
3400    use std::sync::Mutex;
3401
3402    /// Shared `(agent, hint)` log the recording factories append to.
3403    type Seen = Arc<Mutex<Vec<(String, Option<Value>)>>>;
3404
3405    /// Records every `(agent, hint)` pair the compiler hands it, so a test
3406    /// can assert on the hint an agent was built with — the pin's whole
3407    /// effect at this layer.
3408    struct RecordingOperatorFactory {
3409        seen: Seen,
3410    }
3411
3412    impl SpawnerFactory for RecordingOperatorFactory {
3413        fn build(
3414            &self,
3415            agent_def: &AgentDef,
3416            hint: Option<&Value>,
3417        ) -> Result<Arc<dyn SpawnerAdapter>, CompileError> {
3418            self.seen
3419                .lock()
3420                .expect("RecordingOperatorFactory.seen poisoned")
3421                .push((agent_def.name.clone(), hint.cloned()));
3422            let mut spawner: InProcSpawner<LuaWorker> = InProcSpawner::<LuaWorker>::typed();
3423            let worker: WorkerFn = Arc::new(|_inv| {
3424                Box::pin(async move {
3425                    Ok(WorkerResult {
3426                        value: Value::Null,
3427                        ok: true,
3428                        stats: None,
3429                    })
3430                })
3431            });
3432            spawner.registry.insert(agent_def.name.clone(), worker);
3433            Ok(Arc::new(spawner))
3434        }
3435    }
3436
3437    impl SpawnerFactoryKind for RecordingOperatorFactory {
3438        const KIND: AgentKind = AgentKind::Operator;
3439        type Worker = crate::operator::OperatorWorker;
3440    }
3441
3442    /// Same recorder on a non-Operator kind, to prove the pin does not
3443    /// leak onto agents it has no business touching.
3444    struct RecordingLuaFactory {
3445        seen: Seen,
3446    }
3447
3448    impl SpawnerFactory for RecordingLuaFactory {
3449        fn build(
3450            &self,
3451            agent_def: &AgentDef,
3452            hint: Option<&Value>,
3453        ) -> Result<Arc<dyn SpawnerAdapter>, CompileError> {
3454            self.seen
3455                .lock()
3456                .expect("RecordingLuaFactory.seen poisoned")
3457                .push((agent_def.name.clone(), hint.cloned()));
3458            let mut spawner: InProcSpawner<LuaWorker> = InProcSpawner::<LuaWorker>::typed();
3459            let worker: WorkerFn = Arc::new(|_inv| {
3460                Box::pin(async move {
3461                    Ok(WorkerResult {
3462                        value: Value::Null,
3463                        ok: true,
3464                        stats: None,
3465                    })
3466                })
3467            });
3468            spawner.registry.insert(agent_def.name.clone(), worker);
3469            Ok(Arc::new(spawner))
3470        }
3471    }
3472
3473    impl SpawnerFactoryKind for RecordingLuaFactory {
3474        const KIND: AgentKind = AgentKind::Lua;
3475        type Worker = LuaWorker;
3476    }
3477
3478    fn recording_compiler() -> (Compiler, Seen, Seen) {
3479        let operator_seen: Seen = Arc::new(Mutex::new(Vec::new()));
3480        let lua_seen: Seen = Arc::new(Mutex::new(Vec::new()));
3481        let mut registry = SpawnerRegistry::new();
3482        registry.register::<RecordingOperatorFactory>(Arc::new(RecordingOperatorFactory {
3483            seen: operator_seen.clone(),
3484        }));
3485        registry.register::<RecordingLuaFactory>(Arc::new(RecordingLuaFactory {
3486            seen: lua_seen.clone(),
3487        }));
3488        (Compiler::new(registry), operator_seen, lua_seen)
3489    }
3490
3491    /// Two agents (one Operator on role `main-ai`, one Lua) plus an
3492    /// author-declared `hints.per_agent` entry on the Operator one, so the
3493    /// merge behaviour is observable.
3494    fn bp_with_operator_and_lua_agents() -> Blueprint {
3495        serde_json::from_value(serde_json::json!({
3496            "schema_version": crate::blueprint::current_schema_version(),
3497            "id": "operator-pin-ut",
3498            "flow": {
3499                "kind": "step",
3500                "ref": "planner",
3501                "in": { "op": "path", "at": "$.input" },
3502                "out": { "op": "path", "at": "$.output" }
3503            },
3504            "agents": [
3505                {
3506                    "name": "planner",
3507                    "kind": "operator",
3508                    "spec": { "operator_ref": "main-ai" }
3509                },
3510                {
3511                    "name": "scorer",
3512                    "kind": "lua",
3513                    "spec": { "source": "return { value = 1, ok = true }" }
3514                }
3515            ],
3516            "operators": [{ "name": "main-ai" }],
3517            "hints": { "per_agent": { "planner": { "authored": "keep-me" } } },
3518            "strategy": { "strict_refs": false }
3519        }))
3520        .expect("test Blueprint literal")
3521    }
3522
3523    fn hint_for(seen: &Seen, agent: &str) -> Option<Value> {
3524        seen.lock()
3525            .expect("seen poisoned")
3526            .iter()
3527            .find(|(name, _)| name == agent)
3528            .map(|(_, hint)| hint.clone())
3529            .expect("agent was never built")
3530    }
3531
3532    /// Regression lock: the compile synthesizes no build hint of its own.
3533    /// The Operator factory sees exactly the authored hint, and an agent
3534    /// with no authored hint still sees `None` — a compile that quietly
3535    /// added a key here is how a launch-scoped destination got baked in.
3536    #[test]
3537    fn the_compile_hands_the_factory_the_authored_hint_untouched() {
3538        let (compiler, operator_seen, lua_seen) = recording_compiler();
3539        let bp = bp_with_operator_and_lua_agents();
3540        let bound = resolve_bound_agents(&bp).expect("resolve bound agents");
3541        compiler.compile_bound(&bp, &bound).expect("compile");
3542
3543        assert_eq!(
3544            hint_for(&operator_seen, "planner"),
3545            Some(serde_json::json!({ "authored": "keep-me" })),
3546            "the compile must hand over the authored hint verbatim"
3547        );
3548        assert_eq!(
3549            hint_for(&lua_seen, "scorer"),
3550            None,
3551            "an agent with no authored hint must still be built with None"
3552        );
3553    }
3554
3555    /// Whatever shape the author declared for a hint is the author's
3556    /// business — nothing in the compile needs to read or extend it.
3557    #[test]
3558    fn a_non_object_authored_hint_is_none_of_the_compilers_business() {
3559        let (compiler, _operator_seen, _lua_seen) = recording_compiler();
3560        let mut bp = bp_with_operator_and_lua_agents();
3561        bp.hints
3562            .per_agent
3563            .insert("planner".to_string(), Value::String("not-an-object".into()));
3564        let bound = resolve_bound_agents(&bp).expect("resolve bound agents");
3565        assert!(
3566            compiler.compile_bound(&bp, &bound).is_ok(),
3567            "the compile must accept whatever hint shape the author declared"
3568        );
3569    }
3570
3571    // ── factory-level resolution ─────────────────────────────────────────
3572
3573    /// Backend stub whose `requires_worker_binding` doubles as an identity
3574    /// marker: the two registrations below disagree on it, so which one the
3575    /// factory picked is visible in `build`'s outcome alone.
3576    struct StubOperator {
3577        requires_binding: bool,
3578    }
3579
3580    #[async_trait]
3581    impl Operator for StubOperator {
3582        async fn execute(
3583            &self,
3584            _ctx: &Ctx,
3585            _system: Option<String>,
3586            _prompt: Value,
3587            _worker: Option<WorkerBinding>,
3588            _worker_token: CapToken,
3589        ) -> Result<WorkerResult, WorkerError> {
3590            Ok(WorkerResult {
3591                value: Value::Null,
3592                ok: true,
3593                stats: None,
3594            })
3595        }
3596
3597        fn requires_worker_binding(&self) -> bool {
3598            self.requires_binding
3599        }
3600    }
3601
3602    fn operator_agent() -> AgentDef {
3603        AgentDef {
3604            name: "planner".to_string(),
3605            kind: AgentKind::Operator,
3606            spec: serde_json::json!({ "operator_ref": "main-ai" }),
3607            profile: None,
3608            meta: None,
3609            runner: None,
3610            runner_ref: None,
3611            verdict: None,
3612            lints: None,
3613        }
3614    }
3615
3616    /// Answers a fixed set of seats, recording which ones it was asked
3617    /// for — a stand-in for the host wiring that hands back a per-dispatch
3618    /// holder lookup.
3619    struct StubResolver {
3620        seats: Vec<&'static str>,
3621        asked: Mutex<Vec<String>>,
3622    }
3623
3624    impl OperatorSlotResolver for StubResolver {
3625        fn resolve(&self, slot: &str) -> Option<Arc<dyn Operator>> {
3626            self.asked
3627                .lock()
3628                .expect("StubResolver.asked poisoned")
3629                .push(slot.to_string());
3630            self.seats.contains(&slot).then(|| {
3631                Arc::new(StubOperator {
3632                    requires_binding: false,
3633                }) as Arc<dyn Operator>
3634            })
3635        }
3636    }
3637
3638    /// An installed resolver answers the declared seat, and the factory's
3639    /// own registry is not consulted at all — the entry sitting under the
3640    /// same name is deliberately one that would reject this agent, so a
3641    /// successful build proves which side answered.
3642    #[test]
3643    fn an_installed_resolver_answers_the_seat_and_the_registry_is_not_consulted() {
3644        let factory = OperatorSpawnerFactory::new();
3645        factory.register_operator(
3646            "main-ai",
3647            Arc::new(StubOperator {
3648                requires_binding: true,
3649            }) as Arc<dyn Operator>,
3650        );
3651        let resolver = Arc::new(StubResolver {
3652            seats: vec!["main-ai"],
3653            asked: Mutex::new(Vec::new()),
3654        });
3655        factory.set_slot_resolver(resolver.clone());
3656
3657        assert!(
3658            factory.build(&operator_agent(), None).is_ok(),
3659            "the installed resolver must answer the seat, not the registry entry \
3660             registered under the same name"
3661        );
3662        assert_eq!(
3663            *resolver.asked.lock().expect("asked"),
3664            vec!["main-ai".to_string()],
3665            "the resolver is asked for the seat the AgentDef declares"
3666        );
3667    }
3668
3669    /// A resolver that serves no such seat fails the build loudly. There is
3670    /// no second chance from the factory's own registry — falling back is
3671    /// how a dispatch reaches a backend the seat never named.
3672    #[test]
3673    fn a_resolver_miss_fails_loud_and_never_falls_back_to_the_registry() {
3674        let factory = OperatorSpawnerFactory::new();
3675        factory.register_operator(
3676            "main-ai",
3677            Arc::new(StubOperator {
3678                requires_binding: false,
3679            }) as Arc<dyn Operator>,
3680        );
3681        factory.set_slot_resolver(Arc::new(StubResolver {
3682            seats: vec!["some-other-seat"],
3683            asked: Mutex::new(Vec::new()),
3684        }));
3685
3686        match factory.build(&operator_agent(), None) {
3687            Err(CompileError::InvalidSpec { name, msg }) => {
3688                assert_eq!(name, "planner");
3689                assert!(
3690                    msg.contains("main-ai"),
3691                    "message must name the seat that went unserved: {msg}"
3692                );
3693                assert!(
3694                    msg.contains("OperatorSlotResolver"),
3695                    "message must say which side refused, so the wiring is the \
3696                     obvious suspect: {msg}"
3697                );
3698            }
3699            Err(other) => panic!("expected InvalidSpec, got a different CompileError: {other}"),
3700            Ok(_) => panic!(
3701                "an unserved seat must fail the compile, not silently resolve the \
3702                 registry entry"
3703            ),
3704        }
3705    }
3706
3707    /// With no resolver installed the factory keeps its direct registry
3708    /// binding — the wiring `mse bp doctor` and in-process embeddings use.
3709    #[test]
3710    fn without_a_resolver_the_registry_answers_with_the_historical_message() {
3711        let factory = OperatorSpawnerFactory::new();
3712        match factory.build(&operator_agent(), None) {
3713            Err(CompileError::InvalidSpec { msg, .. }) => {
3714                assert!(
3715                    msg.contains("operator_ref 'main-ai' not registered in factory"),
3716                    "the registry-side message must stay the historical one: {msg}"
3717                );
3718            }
3719            Err(other) => panic!("expected InvalidSpec, got a different CompileError: {other}"),
3720            Ok(_) => panic!("an unregistered seat must still fail"),
3721        }
3722        factory.register_operator(
3723            "main-ai",
3724            Arc::new(StubOperator {
3725                requires_binding: false,
3726            }) as Arc<dyn Operator>,
3727        );
3728        assert!(
3729            factory.build(&operator_agent(), None).is_ok(),
3730            "a registered backend must still resolve the seat directly"
3731        );
3732    }
3733}
3734
3735// ─── GH #27 (follow-up to #23): `Blueprint.projection_placement` compile-time
3736// validation + `CompiledBlueprint.projection_placement` construction ────────
3737#[cfg(test)]
3738mod projection_placement_compile_tests {
3739    use super::*;
3740    use crate::core::projection_placement::{ProjectionPlacement, RootPreference};
3741    use crate::worker::adapter::WorkerResult;
3742    use mlua_swarm_schema::ProjectionPlacementSpec;
3743
3744    fn registry_with_echo() -> SpawnerRegistry {
3745        let factory = RustFnInProcessSpawnerFactory::new().register_fn("echo", |inv| async move {
3746            Ok(WorkerResult {
3747                value: Value::String(inv.prompt),
3748                ok: true,
3749                stats: None,
3750            })
3751        });
3752        let mut reg = SpawnerRegistry::new();
3753        reg.register::<RustFnInProcessSpawnerFactory>(Arc::new(factory));
3754        reg
3755    }
3756
3757    fn minimal_bp(projection_placement: Option<ProjectionPlacementSpec>) -> Blueprint {
3758        Blueprint {
3759            schema_version: crate::blueprint::current_schema_version(),
3760            id: "projection-placement-ut".into(),
3761            flow: FlowNode::Step {
3762                ref_: "worker".to_string(),
3763                in_: Expr::Path {
3764                    at: "$.input".parse().expect("literal test path: $.input"),
3765                },
3766                out: Expr::Path {
3767                    at: "$.output".parse().expect("literal test path: $.output"),
3768                },
3769            },
3770            agents: vec![AgentDef {
3771                name: "worker".to_string(),
3772                kind: AgentKind::RustFn,
3773                spec: serde_json::json!({ "fn_id": "echo" }),
3774                profile: None,
3775                meta: None,
3776                runner: None,
3777                runner_ref: None,
3778                verdict: None,
3779                lints: None,
3780            }],
3781            operators: vec![],
3782            metas: vec![],
3783            hints: Default::default(),
3784            strategy: Default::default(),
3785            metadata: BlueprintMetadata::default(),
3786            spawner_hints: Default::default(),
3787            default_agent_kind: AgentKind::Operator,
3788            default_operator_kind: None,
3789            default_init_ctx: None,
3790            default_agent_ctx: None,
3791            default_context_policy: None,
3792            projection_placement,
3793            audits: vec![],
3794            degradation_policy: None,
3795            runners: vec![],
3796            default_runner: None,
3797            subprocesses: vec![],
3798            check_policy: None,
3799            blueprint_ref_includes: Vec::new(),
3800        }
3801    }
3802
3803    #[test]
3804    fn undeclared_projection_placement_compiles_to_byte_compat_default() {
3805        let bp = minimal_bp(None);
3806        let compiled = Compiler::new(registry_with_echo())
3807            .compile(&bp)
3808            .expect("undeclared projection_placement compiles");
3809        assert_eq!(
3810            *compiled.projection_placement,
3811            ProjectionPlacement::default()
3812        );
3813    }
3814
3815    #[test]
3816    fn declared_valid_projection_placement_compiles_to_matching_resolver() {
3817        let bp = minimal_bp(Some(ProjectionPlacementSpec {
3818            root: Some("project_root".to_string()),
3819            dir_template: Some("custom/{task_id}/out".to_string()),
3820        }));
3821        let compiled = Compiler::new(registry_with_echo())
3822            .compile(&bp)
3823            .expect("valid projection_placement compiles");
3824        assert_eq!(
3825            compiled.projection_placement.root_preference,
3826            RootPreference::ProjectRoot
3827        );
3828        assert_eq!(
3829            compiled.projection_placement.dir_template,
3830            "custom/{task_id}/out"
3831        );
3832    }
3833
3834    #[test]
3835    fn declared_invalid_dir_template_rejects_compile() {
3836        let bp = minimal_bp(Some(ProjectionPlacementSpec {
3837            root: None,
3838            dir_template: Some("workspace/tasks/ctx".to_string()), // missing {task_id}
3839        }));
3840        match Compiler::new(registry_with_echo()).compile(&bp) {
3841            Err(CompileError::InvalidProjectionPlacement(_)) => {}
3842            Err(other) => {
3843                panic!("expected InvalidProjectionPlacement, got a different CompileError: {other}")
3844            }
3845            Ok(_) => {
3846                panic!("expected compile-time rejection for a missing {{task_id}} placeholder")
3847            }
3848        }
3849    }
3850
3851    #[test]
3852    fn declared_invalid_root_literal_rejects_compile() {
3853        let bp = minimal_bp(Some(ProjectionPlacementSpec {
3854            root: Some("nope".to_string()),
3855            dir_template: None,
3856        }));
3857        match Compiler::new(registry_with_echo()).compile(&bp) {
3858            Err(CompileError::InvalidProjectionPlacement(_)) => {}
3859            Err(other) => {
3860                panic!("expected InvalidProjectionPlacement, got a different CompileError: {other}")
3861            }
3862            Ok(_) => panic!("expected compile-time rejection for an invalid root literal"),
3863        }
3864    }
3865}
3866
3867// ─── GH #50: `Blueprint.agents[].verdict` cond↔output-shape lint ──────────
3868#[cfg(test)]
3869mod verdict_contract_lint_tests {
3870    use super::*;
3871    use crate::worker::adapter::WorkerResult;
3872
3873    fn registry_with_echo() -> SpawnerRegistry {
3874        let factory = RustFnInProcessSpawnerFactory::new().register_fn("echo", |inv| async move {
3875            Ok(WorkerResult {
3876                value: Value::String(inv.prompt),
3877                ok: true,
3878                stats: None,
3879            })
3880        });
3881        let mut reg = SpawnerRegistry::new();
3882        reg.register::<RustFnInProcessSpawnerFactory>(Arc::new(factory));
3883        reg
3884    }
3885
3886    fn gate_agent(verdict: Option<VerdictContract>) -> AgentDef {
3887        AgentDef {
3888            name: "gate".to_string(),
3889            kind: AgentKind::RustFn,
3890            spec: serde_json::json!({ "fn_id": "echo" }),
3891            profile: None,
3892            meta: None,
3893            runner: None,
3894            runner_ref: None,
3895            verdict,
3896            lints: None,
3897        }
3898    }
3899
3900    fn minimal_bp(agent: AgentDef, flow: FlowNode) -> Blueprint {
3901        Blueprint {
3902            schema_version: crate::blueprint::current_schema_version(),
3903            id: "verdict-contract-ut".into(),
3904            flow,
3905            agents: vec![agent],
3906            operators: vec![],
3907            metas: vec![],
3908            hints: Default::default(),
3909            strategy: Default::default(),
3910            metadata: BlueprintMetadata::default(),
3911            spawner_hints: Default::default(),
3912            default_agent_kind: AgentKind::Operator,
3913            default_operator_kind: None,
3914            default_init_ctx: None,
3915            default_agent_ctx: None,
3916            default_context_policy: None,
3917            projection_placement: None,
3918            audits: vec![],
3919            degradation_policy: None,
3920            runners: vec![],
3921            default_runner: None,
3922            subprocesses: vec![],
3923            check_policy: None,
3924            blueprint_ref_includes: Vec::new(),
3925        }
3926    }
3927
3928    fn step(ref_: &str, out_path: &str) -> FlowNode {
3929        FlowNode::Step {
3930            ref_: ref_.to_string(),
3931            in_: Expr::Lit { value: Value::Null },
3932            out: Expr::Path {
3933                at: out_path.parse().expect("literal test path"),
3934            },
3935        }
3936    }
3937
3938    fn noop() -> FlowNode {
3939        FlowNode::Seq { children: vec![] }
3940    }
3941
3942    fn eq_cond(path: &str, lit: &str) -> Expr {
3943        Expr::Eq {
3944            lhs: Box::new(Expr::Path {
3945                at: path.parse().expect("literal test path"),
3946            }),
3947            rhs: Box::new(Expr::Lit {
3948                value: Value::String(lit.to_string()),
3949            }),
3950        }
3951    }
3952
3953    fn branch(cond: Expr, then_: FlowNode, else_: FlowNode) -> FlowNode {
3954        FlowNode::Branch {
3955            cond,
3956            then_: Box::new(then_),
3957            else_: Box::new(else_),
3958        }
3959    }
3960
3961    fn body_contract(values: &[&str]) -> VerdictContract {
3962        VerdictContract {
3963            channel: VerdictChannel::Body,
3964            values: values.iter().map(|v| v.to_string()).collect(),
3965        }
3966    }
3967
3968    fn part_contract(values: &[&str]) -> VerdictContract {
3969        VerdictContract {
3970            channel: VerdictChannel::Part,
3971            values: values.iter().map(|v| v.to_string()).collect(),
3972        }
3973    }
3974
3975    #[test]
3976    fn contract_with_correct_body_channel_and_value_compiles() {
3977        let agent = gate_agent(Some(body_contract(&["PASS", "BLOCKED"])));
3978        let flow = FlowNode::Seq {
3979            children: vec![
3980                step("gate", "$.verdict"),
3981                branch(eq_cond("$.verdict", "BLOCKED"), noop(), noop()),
3982            ],
3983        };
3984        let bp = minimal_bp(agent, flow);
3985        assert!(
3986            Compiler::new(registry_with_echo()).compile(&bp).is_ok(),
3987            "a cond addressing the bare step output must match a channel: \"body\" contract"
3988        );
3989    }
3990
3991    #[test]
3992    fn contract_with_correct_part_channel_and_value_compiles() {
3993        let agent = gate_agent(Some(part_contract(&["PASS", "BLOCKED"])));
3994        let flow = FlowNode::Seq {
3995            children: vec![
3996                step("gate", "$.gate"),
3997                branch(eq_cond("$.gate.parts.verdict", "BLOCKED"), noop(), noop()),
3998            ],
3999        };
4000        let bp = minimal_bp(agent, flow);
4001        assert!(
4002            Compiler::new(registry_with_echo()).compile(&bp).is_ok(),
4003            "a cond addressing '<step>.parts.verdict' must match a channel: \"part\" contract"
4004        );
4005    }
4006
4007    #[test]
4008    fn body_channel_contract_rejects_cond_addressing_parts_verdict() {
4009        // Pattern A declared (channel: "body") but the cond addresses the
4010        // Pattern B shape ('$.gate.parts.verdict') instead of the bare
4011        // step output — GH #50 register-time enforcement point 1.
4012        let agent = gate_agent(Some(body_contract(&["PASS", "BLOCKED"])));
4013        let flow = FlowNode::Seq {
4014            children: vec![
4015                step("gate", "$.gate"),
4016                branch(eq_cond("$.gate.parts.verdict", "BLOCKED"), noop(), noop()),
4017            ],
4018        };
4019        let bp = minimal_bp(agent, flow);
4020        match Compiler::new(registry_with_echo()).compile(&bp) {
4021            Err(CompileError::VerdictChannelMismatch {
4022                where_,
4023                agent,
4024                expected_channel,
4025                actual_shape,
4026            }) => {
4027                assert_eq!(agent, "gate");
4028                assert_eq!(expected_channel, "body");
4029                assert_eq!(actual_shape, "part");
4030                assert!(where_.contains("Branch cond"), "where_: {where_}");
4031            }
4032            Err(other) => {
4033                panic!("expected VerdictChannelMismatch, got a different CompileError: {other}")
4034            }
4035            Ok(_) => panic!("expected compile-time rejection for the wrong channel shape"),
4036        }
4037    }
4038
4039    #[test]
4040    fn part_channel_contract_rejects_cond_addressing_bare_output() {
4041        // Inverse of the previous case: channel: "part" declared, but the
4042        // cond addresses the bare step output.
4043        let agent = gate_agent(Some(part_contract(&["PASS", "BLOCKED"])));
4044        let flow = FlowNode::Seq {
4045            children: vec![
4046                step("gate", "$.verdict"),
4047                branch(eq_cond("$.verdict", "BLOCKED"), noop(), noop()),
4048            ],
4049        };
4050        let bp = minimal_bp(agent, flow);
4051        match Compiler::new(registry_with_echo()).compile(&bp) {
4052            Err(CompileError::VerdictChannelMismatch {
4053                agent,
4054                expected_channel,
4055                actual_shape,
4056                ..
4057            }) => {
4058                assert_eq!(agent, "gate");
4059                assert_eq!(expected_channel, "part");
4060                assert_eq!(actual_shape, "body");
4061            }
4062            Err(other) => {
4063                panic!("expected VerdictChannelMismatch, got a different CompileError: {other}")
4064            }
4065            Ok(_) => panic!("expected compile-time rejection for the wrong channel shape"),
4066        }
4067    }
4068
4069    #[test]
4070    fn contract_rejects_lit_outside_declared_values() {
4071        let agent = gate_agent(Some(body_contract(&["PASS", "BLOCKED"])));
4072        let flow = FlowNode::Seq {
4073            children: vec![
4074                step("gate", "$.verdict"),
4075                branch(eq_cond("$.verdict", "UNKNOWN"), noop(), noop()),
4076            ],
4077        };
4078        let bp = minimal_bp(agent, flow);
4079        match Compiler::new(registry_with_echo()).compile(&bp) {
4080            Err(CompileError::VerdictValueNotInContract {
4081                agent,
4082                value,
4083                values,
4084                ..
4085            }) => {
4086                assert_eq!(agent, "gate");
4087                assert_eq!(value, "UNKNOWN");
4088                assert_eq!(values, vec!["PASS".to_string(), "BLOCKED".to_string()]);
4089            }
4090            Err(other) => {
4091                panic!("expected VerdictValueNotInContract, got a different CompileError: {other}")
4092            }
4093            Ok(_) => panic!("expected compile-time rejection for a Lit outside declared values"),
4094        }
4095    }
4096
4097    #[test]
4098    fn undeclared_agent_referenced_by_cond_compiles_with_warning_only() {
4099        let agent = gate_agent(None);
4100        let flow = FlowNode::Seq {
4101            children: vec![
4102                step("gate", "$.verdict"),
4103                branch(eq_cond("$.verdict", "BLOCKED"), noop(), noop()),
4104            ],
4105        };
4106        let bp = minimal_bp(agent, flow);
4107        assert!(
4108            Compiler::new(registry_with_echo()).compile(&bp).is_ok(),
4109            "an undeclared verdict contract must never reject compile (opt-in, back-compat)"
4110        );
4111    }
4112
4113    #[test]
4114    fn in_expr_with_lit_haystack_members_compiles() {
4115        let agent = gate_agent(Some(body_contract(&["PASS", "BLOCKED"])));
4116        let cond = Expr::In {
4117            needle: Box::new(Expr::Path {
4118                at: "$.verdict".parse().expect("literal test path"),
4119            }),
4120            haystack: Box::new(Expr::Lit {
4121                value: serde_json::json!(["PASS", "BLOCKED"]),
4122            }),
4123        };
4124        let flow = FlowNode::Seq {
4125            children: vec![step("gate", "$.verdict"), branch(cond, noop(), noop())],
4126        };
4127        let bp = minimal_bp(agent, flow);
4128        assert!(
4129            Compiler::new(registry_with_echo()).compile(&bp).is_ok(),
4130            "an `In` haystack whose every Lit is a declared value must compile"
4131        );
4132    }
4133
4134    /// GH #50 follow-up (issue `33bc825b`): opt-in strict mode rejects a
4135    /// Blueprint whose declared `verdict.values` set includes at least one
4136    /// entry that no downstream `Branch`/`Loop` `cond` references. The
4137    /// contract declares `["PASS", "BLOCKED"]` but only "BLOCKED" is
4138    /// referenced by the cond → "PASS" is unhandled → `CompileError::
4139    /// VerdictValueUnhandled` under `strict_verdict_handling: Some(true)`.
4140    #[test]
4141    fn strict_mode_rejects_unhandled_declared_value() {
4142        let agent = gate_agent(Some(body_contract(&["PASS", "BLOCKED"])));
4143        let flow = FlowNode::Seq {
4144            children: vec![
4145                step("gate", "$.verdict"),
4146                branch(eq_cond("$.verdict", "BLOCKED"), noop(), noop()),
4147            ],
4148        };
4149        let mut bp = minimal_bp(agent, flow);
4150        bp.metadata.strict_verdict_handling = Some(true);
4151        match Compiler::new(registry_with_echo()).compile(&bp) {
4152            Err(CompileError::VerdictValueUnhandled {
4153                agent,
4154                value,
4155                declared_values,
4156                step_ref,
4157            }) => {
4158                assert_eq!(agent, "gate");
4159                assert_eq!(value, "PASS");
4160                assert_eq!(
4161                    declared_values,
4162                    vec!["PASS".to_string(), "BLOCKED".to_string()]
4163                );
4164                assert_eq!(step_ref, "gate");
4165            }
4166            Err(other) => {
4167                panic!("expected VerdictValueUnhandled, got a different CompileError: {other}")
4168            }
4169            Ok(_) => panic!(
4170                "expected compile-time rejection for a declared verdict value with no \
4171                 downstream handler under strict_verdict_handling=Some(true)"
4172            ),
4173        }
4174    }
4175
4176    /// GH #50 follow-up (issue `33bc825b`): default mode (i.e.
4177    /// `strict_verdict_handling` absent or `Some(false)`) surfaces
4178    /// unhandled declared values via `tracing::warn!` only — the compile
4179    /// still succeeds. This preserves back-compat with GH #50's original
4180    /// test cases (many of which declare `values = ["PASS", "BLOCKED"]`
4181    /// and cond-reference only one).
4182    #[test]
4183    fn default_mode_permits_unhandled_declared_value() {
4184        let agent = gate_agent(Some(body_contract(&["PASS", "BLOCKED"])));
4185        let flow = FlowNode::Seq {
4186            children: vec![
4187                step("gate", "$.verdict"),
4188                branch(eq_cond("$.verdict", "BLOCKED"), noop(), noop()),
4189            ],
4190        };
4191        let bp = minimal_bp(agent, flow);
4192        // `strict_verdict_handling` left as `None` (default)
4193        assert!(
4194            Compiler::new(registry_with_echo()).compile(&bp).is_ok(),
4195            "default mode must never reject a Blueprint for unhandled declared values \
4196             (opt-in, back-compat with GH #50)"
4197        );
4198    }
4199
4200    /// GH #50 follow-up (issue `33bc825b`): under strict mode, when every
4201    /// declared value is referenced by at least one downstream cond, the
4202    /// compile succeeds. This tests the positive path of the reverse-
4203    /// direction lint.
4204    #[test]
4205    fn strict_mode_accepts_all_declared_values_handled() {
4206        let agent = gate_agent(Some(body_contract(&["PASS", "BLOCKED"])));
4207        // Two branches, each cond referencing one declared value —
4208        // together they cover the full `values` set.
4209        let flow = FlowNode::Seq {
4210            children: vec![
4211                step("gate", "$.verdict"),
4212                branch(eq_cond("$.verdict", "BLOCKED"), noop(), noop()),
4213                branch(eq_cond("$.verdict", "PASS"), noop(), noop()),
4214            ],
4215        };
4216        let mut bp = minimal_bp(agent, flow);
4217        bp.metadata.strict_verdict_handling = Some(true);
4218        assert!(
4219            Compiler::new(registry_with_echo()).compile(&bp).is_ok(),
4220            "strict mode must accept a Blueprint that handles every declared value"
4221        );
4222    }
4223
4224    /// GH #50 follow-up (issue `33bc825b`): under strict mode, an `In`
4225    /// cond whose `Lit` haystack lists every declared value satisfies
4226    /// the handler-coverage check in one go.
4227    #[test]
4228    fn strict_mode_accepts_declared_values_covered_by_in_expr() {
4229        let agent = gate_agent(Some(body_contract(&["PASS", "BLOCKED"])));
4230        let cond = Expr::In {
4231            needle: Box::new(Expr::Path {
4232                at: "$.verdict".parse().expect("literal test path"),
4233            }),
4234            haystack: Box::new(Expr::Lit {
4235                value: serde_json::json!(["PASS", "BLOCKED"]),
4236            }),
4237        };
4238        let flow = FlowNode::Seq {
4239            children: vec![step("gate", "$.verdict"), branch(cond, noop(), noop())],
4240        };
4241        let mut bp = minimal_bp(agent, flow);
4242        bp.metadata.strict_verdict_handling = Some(true);
4243        assert!(
4244            Compiler::new(registry_with_echo()).compile(&bp).is_ok(),
4245            "strict mode must accept an `In` haystack that covers every declared value"
4246        );
4247    }
4248
4249    /// GH #50 follow-up (issue `33bc825b`): under strict mode, a `part`
4250    /// channel contract with unhandled declared value is rejected the same
4251    /// way as the `body` channel case. Confirms channel-agnostic coverage.
4252    #[test]
4253    fn strict_mode_rejects_unhandled_part_channel_value() {
4254        let agent = gate_agent(Some(part_contract(&["PASS", "BLOCKED"])));
4255        let flow = FlowNode::Seq {
4256            children: vec![
4257                step("gate", "$.gate"),
4258                branch(eq_cond("$.gate.parts.verdict", "BLOCKED"), noop(), noop()),
4259            ],
4260        };
4261        let mut bp = minimal_bp(agent, flow);
4262        bp.metadata.strict_verdict_handling = Some(true);
4263        match Compiler::new(registry_with_echo()).compile(&bp) {
4264            Err(CompileError::VerdictValueUnhandled {
4265                agent,
4266                value,
4267                step_ref,
4268                ..
4269            }) => {
4270                assert_eq!(agent, "gate");
4271                assert_eq!(value, "PASS");
4272                assert_eq!(step_ref, "gate");
4273            }
4274            Err(other) => {
4275                panic!("expected VerdictValueUnhandled, got a different CompileError: {other}")
4276            }
4277            Ok(_) => panic!(
4278                "expected compile-time rejection for a declared verdict value with no \
4279                 downstream handler (part channel) under strict_verdict_handling=Some(true)"
4280            ),
4281        }
4282    }
4283
4284    // ─── lints at the compile stage (design §5) ──────────────────────
4285    // Two layers: `agents[].lints` (nearer) then `metadata.lints`.
4286
4287    /// A `lints` map, as the schema's author-facing form — used for both
4288    /// the agent and the Blueprint layer.
4289    fn lints(
4290        pairs: &[(&str, mlua_swarm_schema::LintSetting)],
4291    ) -> Option<std::collections::BTreeMap<String, mlua_swarm_schema::LintSetting>> {
4292        Some(
4293            pairs
4294                .iter()
4295                .map(|(key, setting)| ((*key).to_string(), *setting))
4296                .collect(),
4297        )
4298    }
4299
4300    /// The `unhandled_gate` fixture: the contract declares
4301    /// `["PASS", "BLOCKED"]` but only "BLOCKED" is cond-referenced, so
4302    /// "PASS" is unhandled and the gate decides what happens.
4303    fn bp_with_unhandled_value() -> Blueprint {
4304        let agent = gate_agent(Some(body_contract(&["PASS", "BLOCKED"])));
4305        let flow = FlowNode::Seq {
4306            children: vec![
4307                step("gate", "$.verdict"),
4308                branch(eq_cond("$.verdict", "BLOCKED"), noop(), noop()),
4309            ],
4310        };
4311        minimal_bp(agent, flow)
4312    }
4313
4314    /// The same contract-bearing agent as [`gate_agent`] under a chosen
4315    /// name, so one agent's `lints` can be observed against a sibling
4316    /// that declares none.
4317    fn named_agent(name: &str, verdict: Option<VerdictContract>) -> AgentDef {
4318        AgentDef {
4319            name: name.to_string(),
4320            ..gate_agent(verdict)
4321        }
4322    }
4323
4324    /// Two contract-bearing agents, each declaring `["PASS", "BLOCKED"]`
4325    /// with only "BLOCKED" cond-referenced — so *both* have an unhandled
4326    /// "PASS" and the per-agent layer is what tells them apart.
4327    fn bp_with_two_unhandled_agents() -> Blueprint {
4328        let flow = FlowNode::Seq {
4329            children: vec![
4330                step("researcher", "$.researcher_verdict"),
4331                step("reviewer", "$.reviewer_verdict"),
4332                branch(eq_cond("$.researcher_verdict", "BLOCKED"), noop(), noop()),
4333                branch(eq_cond("$.reviewer_verdict", "BLOCKED"), noop(), noop()),
4334            ],
4335        };
4336        let mut bp = minimal_bp(
4337            named_agent("researcher", Some(body_contract(&["PASS", "BLOCKED"]))),
4338            flow,
4339        );
4340        bp.agents.push(named_agent(
4341            "reviewer",
4342            Some(body_contract(&["PASS", "BLOCKED"])),
4343        ));
4344        bp
4345    }
4346
4347    /// An `agents[].lints` deny reaches the compile stage, and reaches
4348    /// only the agent that declared it: the sibling's identical unhandled
4349    /// value stays a `tracing::warn!` (so the only error is the declaring
4350    /// agent's).
4351    #[test]
4352    fn agent_lints_deny_rejects_only_the_declaring_agent() {
4353        let mut bp = bp_with_two_unhandled_agents();
4354        bp.agents[0].lints = lints(&[(
4355            "verdict-value-unhandled",
4356            mlua_swarm_schema::LintSetting::Deny,
4357        )]);
4358        match Compiler::new(registry_with_echo()).compile(&bp) {
4359            Err(CompileError::VerdictValueUnhandled { agent, value, .. }) => {
4360                assert_eq!(agent, "researcher", "the sibling only warns");
4361                assert_eq!(value, "PASS");
4362            }
4363            Err(other) => {
4364                panic!("expected VerdictValueUnhandled, got a different CompileError: {other}")
4365            }
4366            Ok(_) => panic!(
4367                "expected compile-time rejection under \
4368                 agents[0].lints = {{\"verdict-value-unhandled\": \"deny\"}}"
4369            ),
4370        }
4371    }
4372
4373    /// Proximity: the agent layer wins outright over the Blueprint layer,
4374    /// so an agent-level `allow` silences that agent while a
4375    /// Blueprint-level `deny` still rejects its sibling.
4376    #[test]
4377    fn agent_allow_beats_blueprint_deny_for_that_agent() {
4378        let mut bp = bp_with_two_unhandled_agents();
4379        bp.metadata.lints = lints(&[(
4380            "verdict-value-unhandled",
4381            mlua_swarm_schema::LintSetting::Deny,
4382        )]);
4383        bp.agents[0].lints = lints(&[(
4384            "verdict-value-unhandled",
4385            mlua_swarm_schema::LintSetting::Allow,
4386        )]);
4387        match Compiler::new(registry_with_echo()).compile(&bp) {
4388            Err(CompileError::VerdictValueUnhandled { agent, .. }) => {
4389                assert_eq!(
4390                    agent, "reviewer",
4391                    "the allowing agent is silenced; the sibling still denies"
4392                );
4393            }
4394            Err(other) => {
4395                panic!("expected VerdictValueUnhandled, got a different CompileError: {other}")
4396            }
4397            Ok(_) => panic!("the sibling agent's Blueprint-level deny must still reject"),
4398        }
4399    }
4400
4401    /// Union toward deny at the agent layer too: the legacy strict flag
4402    /// wins over an `agents[].lints` `allow`, exactly as it does over a
4403    /// `metadata.lints` one.
4404    #[test]
4405    fn strict_flag_wins_over_agent_lints_allow() {
4406        let mut bp = bp_with_unhandled_value();
4407        bp.metadata.strict_verdict_handling = Some(true);
4408        bp.agents[0].lints = lints(&[("all", mlua_swarm_schema::LintSetting::Allow)]);
4409        match Compiler::new(registry_with_echo()).compile(&bp) {
4410            Err(CompileError::VerdictValueUnhandled { agent, .. }) => assert_eq!(agent, "gate"),
4411            Err(other) => {
4412                panic!("expected VerdictValueUnhandled, got a different CompileError: {other}")
4413            }
4414            Ok(_) => panic!(
4415                "strict_verdict_handling=Some(true) must still reject under an agent-level allow"
4416            ),
4417        }
4418    }
4419
4420    /// Within the agent layer, the category group key reaches the kind —
4421    /// same specificity ladder as the Blueprint layer.
4422    #[test]
4423    fn agent_category_key_reaches_the_kind() {
4424        let mut bp = bp_with_two_unhandled_agents();
4425        bp.agents[0].lints =
4426            lints(&[("category:suspicious", mlua_swarm_schema::LintSetting::Deny)]);
4427        match Compiler::new(registry_with_echo()).compile(&bp) {
4428            Err(CompileError::VerdictValueUnhandled { agent, .. }) => assert_eq!(
4429                agent, "researcher",
4430                "a category: group deny must reach the kind it covers, on the declaring agent"
4431            ),
4432            Err(other) => {
4433                panic!("expected VerdictValueUnhandled, got a different CompileError: {other}")
4434            }
4435            Ok(_) => panic!("expected compile-time rejection under an agent-level category deny"),
4436        }
4437    }
4438
4439    /// An agent that declares nothing inherits the Blueprint layer, and
4440    /// an agent-level `allow` does not leak onto it.
4441    #[test]
4442    fn agent_without_lints_inherits_the_blueprint_layer() {
4443        let mut bp = bp_with_two_unhandled_agents();
4444        bp.metadata.lints = lints(&[("all", mlua_swarm_schema::LintSetting::Allow)]);
4445        assert!(
4446            Compiler::new(registry_with_echo()).compile(&bp).is_ok(),
4447            "a Blueprint-wide allow covers every agent that declares nothing"
4448        );
4449
4450        let gates = resolve_unhandled_verdict_gates(&bp);
4451        assert_eq!(gates.for_agent("reviewer"), UnhandledVerdictGate::Silence);
4452        assert!(gates.all_silent());
4453
4454        bp.agents[0].lints = lints(&[(
4455            "verdict-value-unhandled",
4456            mlua_swarm_schema::LintSetting::Warn,
4457        )]);
4458        let gates = resolve_unhandled_verdict_gates(&bp);
4459        assert_eq!(
4460            gates.for_agent("researcher"),
4461            UnhandledVerdictGate::Warn,
4462            "the agent's own layer wins over the Blueprint's allow"
4463        );
4464        assert_eq!(
4465            gates.for_agent("reviewer"),
4466            UnhandledVerdictGate::Silence,
4467            "the sibling keeps the Blueprint layer"
4468        );
4469        assert!(!gates.all_silent());
4470    }
4471
4472    /// `metadata.lints = {"verdict-value-unhandled": "deny"}` rejects the
4473    /// same Blueprint `strict_verdict_handling: Some(true)` rejects —
4474    /// without the legacy flag being set at all.
4475    #[test]
4476    fn lints_deny_rejects_unhandled_declared_value() {
4477        let mut bp = bp_with_unhandled_value();
4478        bp.metadata.lints = lints(&[(
4479            "verdict-value-unhandled",
4480            mlua_swarm_schema::LintSetting::Deny,
4481        )]);
4482        match Compiler::new(registry_with_echo()).compile(&bp) {
4483            Err(CompileError::VerdictValueUnhandled { agent, value, .. }) => {
4484                assert_eq!(agent, "gate");
4485                assert_eq!(value, "PASS");
4486            }
4487            Err(other) => {
4488                panic!("expected VerdictValueUnhandled, got a different CompileError: {other}")
4489            }
4490            Ok(_) => panic!(
4491                "expected compile-time rejection under \
4492                 metadata.lints = {{\"verdict-value-unhandled\": \"deny\"}}"
4493            ),
4494        }
4495    }
4496
4497    /// The kind's category group key reaches it too — `verdict-value-
4498    /// unhandled` is `LintCategory::Suspicious`.
4499    #[test]
4500    fn lints_category_deny_rejects_unhandled_declared_value() {
4501        let mut bp = bp_with_unhandled_value();
4502        bp.metadata.lints = lints(&[("category:suspicious", mlua_swarm_schema::LintSetting::Deny)]);
4503        assert!(
4504            matches!(
4505                Compiler::new(registry_with_echo()).compile(&bp),
4506                Err(CompileError::VerdictValueUnhandled { .. })
4507            ),
4508            "a category: group deny must reach the kind it covers"
4509        );
4510    }
4511
4512    /// `allow` silences the warn-only default; the compile still succeeds
4513    /// (the observable difference from the default is asserted directly on
4514    /// [`resolve_unhandled_verdict_gate`] below).
4515    #[test]
4516    fn lints_allow_compiles_and_silences_the_warn() {
4517        let mut bp = bp_with_unhandled_value();
4518        bp.metadata.lints = lints(&[(
4519            "verdict-value-unhandled",
4520            mlua_swarm_schema::LintSetting::Allow,
4521        )]);
4522        assert!(
4523            Compiler::new(registry_with_echo()).compile(&bp).is_ok(),
4524            "an allowed lint must never reject the compile"
4525        );
4526        assert_eq!(
4527            resolve_unhandled_verdict_gate(&bp.metadata),
4528            UnhandledVerdictGate::Silence
4529        );
4530    }
4531
4532    /// Union toward deny: the legacy flag wins over a `lints` `allow`, so
4533    /// an existing strict Blueprint cannot be silently softened by a broad
4534    /// `all` / `category:` key.
4535    #[test]
4536    fn strict_flag_wins_over_lints_allow() {
4537        let mut bp = bp_with_unhandled_value();
4538        bp.metadata.strict_verdict_handling = Some(true);
4539        bp.metadata.lints = lints(&[("all", mlua_swarm_schema::LintSetting::Allow)]);
4540        assert!(
4541            matches!(
4542                Compiler::new(registry_with_echo()).compile(&bp),
4543                Err(CompileError::VerdictValueUnhandled { .. })
4544            ),
4545            "strict_verdict_handling=Some(true) must still reject under a lints allow"
4546        );
4547    }
4548
4549    /// The gate's full resolution table, including the two cases the
4550    /// compile result cannot tell apart (warn vs silence).
4551    #[test]
4552    fn unhandled_verdict_gate_resolution_table() {
4553        use mlua_swarm_schema::LintSetting;
4554
4555        let gate = |strict, map| {
4556            resolve_unhandled_verdict_gate(&BlueprintMetadata {
4557                strict_verdict_handling: strict,
4558                lints: map,
4559                ..Default::default()
4560            })
4561        };
4562        let kind = "verdict-value-unhandled";
4563
4564        assert_eq!(gate(None, None), UnhandledVerdictGate::Warn);
4565        assert_eq!(gate(Some(false), None), UnhandledVerdictGate::Warn);
4566        assert_eq!(gate(Some(true), None), UnhandledVerdictGate::Deny);
4567        assert_eq!(
4568            gate(None, lints(&[(kind, LintSetting::Deny)])),
4569            UnhandledVerdictGate::Deny
4570        );
4571        assert_eq!(
4572            gate(None, lints(&[(kind, LintSetting::Warn)])),
4573            UnhandledVerdictGate::Warn
4574        );
4575        assert_eq!(
4576            gate(None, lints(&[(kind, LintSetting::Allow)])),
4577            UnhandledVerdictGate::Silence
4578        );
4579        assert_eq!(
4580            gate(Some(true), lints(&[(kind, LintSetting::Allow)])),
4581            UnhandledVerdictGate::Deny,
4582            "strict wins over allow"
4583        );
4584        // Within-layer specificity: the exact kind beats the group.
4585        assert_eq!(
4586            gate(
4587                None,
4588                lints(&[
4589                    (kind, LintSetting::Allow),
4590                    ("category:suspicious", LintSetting::Deny),
4591                ])
4592            ),
4593            UnhandledVerdictGate::Silence
4594        );
4595        // Unknown keys are meta-lint material at bp_doctor, never a
4596        // compile-stage signal.
4597        assert_eq!(
4598            gate(None, lints(&[("no-such-lint", LintSetting::Deny)])),
4599            UnhandledVerdictGate::Warn
4600        );
4601    }
4602
4603    /// `metadata.lints` has exactly one compile-stage effect. Every other
4604    /// `CompileError` is a hard error, not a lint: a blanket `all` allow
4605    /// leaves them rejecting (design §3 non-suppressible boundary).
4606    #[test]
4607    fn lints_never_soften_other_compile_errors() {
4608        let mut bp = bp_with_unhandled_value();
4609        bp.agents.push(gate_agent(None));
4610        bp.metadata.lints = lints(&[("all", mlua_swarm_schema::LintSetting::Allow)]);
4611        assert!(
4612            matches!(
4613                Compiler::new(registry_with_echo()).compile(&bp),
4614                Err(CompileError::DuplicateAgent(name)) if name == "gate"
4615            ),
4616            "an `all` allow must not suppress a compile hard error"
4617        );
4618    }
4619
4620    /// Acceptance criterion #7 (5th case): a Blueprint shaped like the
4621    /// existing `02-verdict-loop.json` sample — a `Loop` retrying while
4622    /// `$.verdict == "BLOCKED"` plus a `Branch` on `$.verdict == "PASS"` —
4623    /// but with `verdict` omitted on every agent must compile unchanged
4624    /// (at most `tracing::warn!`) and leave `CompiledAgentTable.
4625    /// verdict_contracts` empty.
4626    #[test]
4627    fn verdict_omitted_blueprint_compiles_unchanged_with_empty_contracts() {
4628        let agent = gate_agent(None);
4629        let flow = FlowNode::Seq {
4630            children: vec![
4631                step("gate", "$.verdict"),
4632                FlowNode::Loop {
4633                    counter: Expr::Path {
4634                        at: "$.n".parse().expect("literal test path"),
4635                    },
4636                    cond: eq_cond("$.verdict", "BLOCKED"),
4637                    body: Box::new(step("gate", "$.verdict")),
4638                    max: 3,
4639                },
4640                branch(eq_cond("$.verdict", "PASS"), noop(), noop()),
4641            ],
4642        };
4643        let bp = minimal_bp(agent, flow);
4644        let compiled = Compiler::new(registry_with_echo())
4645            .compile(&bp)
4646            .expect("a verdict-omitted Blueprint must compile unchanged");
4647        assert!(
4648            compiled.router.verdict_contracts.is_empty(),
4649            "no agent declared a verdict contract"
4650        );
4651    }
4652
4653    // ─── GH #79 Phase 2: CompileError → Diagnostic projection ────────
4654
4655    /// Every `kind` key the `From<&CompileError>` impl can emit must be
4656    /// declared in `mlua_swarm_diag::LINT_DECLS` (the exhaustiveness of
4657    /// the variant mapping itself is enforced by the compiler — the
4658    /// `match` in the impl has no wildcard arm).
4659    #[test]
4660    fn every_compile_error_diagnostic_kind_is_a_declared_lint() {
4661        let kinds = [
4662            "bound-agent-resolution",
4663            "unknown-agent-kind",
4664            "invalid-agent-spec",
4665            "worker-binding-missing",
4666            "unresolved-agent-ref",
4667            "duplicate-agent-name",
4668            "unresolved-operator-ref",
4669            "unresolved-meta-ref",
4670            "step-naming-collision",
4671            "invalid-projection-placement",
4672            "unresolved-audit-agent",
4673            "verdict-channel-mismatch",
4674            "verdict-value-not-in-contract",
4675            "verdict-value-unhandled",
4676            "removed-spawner-hint",
4677        ];
4678        for kind in kinds {
4679            assert!(
4680                mlua_swarm_diag::lint_decl(kind).is_some(),
4681                "kind '{kind}' emitted by From<&CompileError> has no LINT_DECLS entry"
4682            );
4683        }
4684    }
4685
4686    #[test]
4687    fn invalid_spec_with_worker_binding_prefix_specializes_the_diagnostic_kind() {
4688        // The factory's message construction and the From matcher share
4689        // WORKER_BINDING_REQUIRED_MSG_PREFIX, so building the error the
4690        // way the factory does must hit the specialized arm.
4691        let err = CompileError::InvalidSpec {
4692            name: "greeter".into(),
4693            msg: format!("{WORKER_BINDING_REQUIRED_MSG_PREFIX}. Fix by either: (a) ..."),
4694        };
4695        let d = mlua_swarm_diag::Diagnostic::from(&err);
4696        assert_eq!(d.kind, "worker-binding-missing");
4697        assert_eq!(d.level, mlua_swarm_diag::DiagLevel::Error);
4698        assert!(matches!(d.stage, mlua_swarm_diag::DiagStage::CompileLint));
4699        assert!(d.message.contains("greeter"));
4700        let suggestion = d
4701            .suggestion
4702            .expect("specialized arm must carry a suggestion");
4703        assert!(suggestion.patch.contains("backend = \"ws_operator\""));
4704        assert_eq!(
4705            suggestion.applicability,
4706            mlua_swarm_diag::Applicability::HasPlaceholders
4707        );
4708        assert_eq!(
4709            d.docs_ref.expect("docs_ref must be set").uri,
4710            "mse://guides/bp-dsl-templates"
4711        );
4712        match d.span.expect("span must be set").element {
4713            mlua_swarm_diag::DiagElement::Agent { name } => assert_eq!(name, "greeter"),
4714            other => panic!("expected Agent span, got {other:?}"),
4715        }
4716    }
4717
4718    // ─── Removed spawner-hint layer (`operator_delegate`) ────────────
4719    //
4720    // The delegate axis is gone. `service::linker::link` skips hint keys
4721    // the deployment does not install, so without an explicit refusal a
4722    // Blueprint that still declares the key would compile, register, and
4723    // run a different execution shape in silence. These pin the refusal,
4724    // the fact that ordinary unknown keys keep their leniency, and the
4725    // author-facing content of the diagnostic.
4726
4727    fn bp_with_hint_layers(layers: &[&str]) -> Blueprint {
4728        let mut bp = minimal_bp(gate_agent(None), step("gate", "$.verdict"));
4729        bp.spawner_hints = mlua_swarm_schema::SpawnerHints {
4730            layers: layers.iter().map(|s| s.to_string()).collect(),
4731        };
4732        bp
4733    }
4734
4735    #[test]
4736    fn declaring_the_removed_operator_delegate_layer_fails_the_compile() {
4737        let bp = bp_with_hint_layers(&["operator_delegate"]);
4738        match Compiler::new(registry_with_echo()).compile(&bp) {
4739            Err(CompileError::RemovedSpawnerHint { key, reason }) => {
4740                assert_eq!(key, "operator_delegate");
4741                assert!(
4742                    reason.contains("Run.current") && reason.contains("system"),
4743                    "the reason must name both defects the removal was for, so the error \
4744                     alone explains why: {reason}"
4745                );
4746            }
4747            Err(other) => panic!("expected RemovedSpawnerHint, got: {other}"),
4748            Ok(_) => panic!(
4749                "a Blueprint declaring the removed operator_delegate layer must not compile — \
4750                 otherwise the layer silently stops applying"
4751            ),
4752        }
4753    }
4754
4755    /// The leniency that still applies: a key this deployment does not
4756    /// install is skipped, not refused. Removing a layer must not turn
4757    /// `spawner_hints.layers` into a closed set, or every Blueprint
4758    /// written against a deployment with extra layers stops compiling
4759    /// here.
4760    #[test]
4761    fn an_unknown_but_not_removed_layer_key_still_compiles() {
4762        let bp = bp_with_hint_layers(&["main_ai", "some_deployment_specific_layer"]);
4763        Compiler::new(registry_with_echo())
4764            .compile(&bp)
4765            .expect("unknown (as opposed to removed) hint keys stay leniently skipped");
4766    }
4767
4768    #[test]
4769    fn a_blueprint_declaring_no_layers_compiles() {
4770        let bp = bp_with_hint_layers(&[]);
4771        Compiler::new(registry_with_echo())
4772            .compile(&bp)
4773            .expect("the all-clear case must produce no finding at all");
4774    }
4775
4776    #[test]
4777    fn removed_spawner_hint_projects_a_migration_diagnostic_naming_the_replacement() {
4778        let err = CompileError::RemovedSpawnerHint {
4779            key: "operator_delegate".into(),
4780            reason: "it could not follow a handover".into(),
4781        };
4782        let d = mlua_swarm_diag::Diagnostic::from(&err);
4783
4784        assert_eq!(d.kind, "removed-spawner-hint");
4785        assert_eq!(d.level, mlua_swarm_diag::DiagLevel::Error);
4786        assert!(matches!(d.stage, mlua_swarm_diag::DiagStage::CompileLint));
4787
4788        // The kind resolves in the registry, and is filed as a migration
4789        // (a deprecated surface with a replacement) rather than a plain
4790        // correctness bug.
4791        let decl = mlua_swarm_diag::lint_decl("removed-spawner-hint")
4792            .expect("the kind must be declared in LINT_DECLS");
4793        assert_eq!(decl.category, mlua_swarm_diag::LintCategory::Migration);
4794
4795        // What the author is actually told: where to go instead.
4796        let help = d.help.as_ref().expect("help must name the replacement");
4797        assert!(
4798            help.contains("operators[]")
4799                && help.contains("spec.operator_ref")
4800                && help.contains("operator_sid"),
4801            "the help line must name all three parts of the AgentSpec axis an author has to \
4802             write, not just say the old one is gone: {help}"
4803        );
4804
4805        // Whole-value equality against the shared constructor, not
4806        // `patch.contains("operator_ref")`: the substring form passes on
4807        // any two texts that both mention the field, which is exactly what
4808        // a drifted copy of this prose would do. `bp_doctor`'s arm calls
4809        // the same constructor, so this also pins the two stages together
4810        // — the failure mode named in
4811        // `removed_spawner_hint_suggestion`'s doc.
4812        let suggestion = d.suggestion.expect("a concrete patch must be attached");
4813        assert_eq!(
4814            suggestion,
4815            mlua_swarm_diag::removed_spawner_hint_suggestion()
4816        );
4817        assert!(
4818            suggestion.patch.contains("\"operator_ref\""),
4819            "whatever else the shared patch says, it has to show the field an author \
4820             must add: {}",
4821            suggestion.patch
4822        );
4823        assert_eq!(
4824            suggestion.applicability,
4825            // Not MachineApplicable: the author has to pick which agents
4826            // get an `operator_ref` and what the seat is called.
4827            mlua_swarm_diag::Applicability::HasPlaceholders
4828        );
4829
4830        assert_eq!(
4831            d.docs_ref.expect("docs_ref must be set").uri,
4832            "mse://guides/blueprint-authoring"
4833        );
4834        assert!(matches!(
4835            d.span.expect("span must be set").element,
4836            mlua_swarm_diag::DiagElement::BlueprintRoot
4837        ));
4838    }
4839
4840    #[test]
4841    fn generic_invalid_spec_maps_to_the_generic_kind() {
4842        let err = CompileError::InvalidSpec {
4843            name: "solo".into(),
4844            msg: "operator spec: 'operator_ref' (string) required".into(),
4845        };
4846        let d = mlua_swarm_diag::Diagnostic::from(&err);
4847        assert_eq!(d.kind, "invalid-agent-spec");
4848        assert!(
4849            d.suggestion.is_none(),
4850            "generic arm carries no canned patch"
4851        );
4852    }
4853
4854    #[test]
4855    fn verdict_value_not_in_contract_diagnostic_carries_suggestion_and_span() {
4856        let err = CompileError::VerdictValueNotInContract {
4857            where_: "Branch cond".into(),
4858            agent: "review".into(),
4859            value: "NOT_DECLARED".into(),
4860            values: vec!["PASS".into(), "BLOCKED".into()],
4861        };
4862        let d = mlua_swarm_diag::Diagnostic::from(&err);
4863        assert_eq!(d.kind, "verdict-value-not-in-contract");
4864        assert!(d.message.contains("NOT_DECLARED"));
4865        assert!(d.suggestion.is_some());
4866        match d.span.expect("span must be set").element {
4867            mlua_swarm_diag::DiagElement::Agent { name } => assert_eq!(name, "review"),
4868            other => panic!("expected Agent span, got {other:?}"),
4869        }
4870    }
4871}
4872
4873// ─── GH #83: SubprocessDef template hint + placeholder validation ─────────
4874#[cfg(test)]
4875mod subprocess_embed_compile_tests {
4876    use super::*;
4877    use mlua_swarm_schema::{current_schema_version, SubprocessDef, SubprocessOverrides};
4878
4879    fn subprocess_agent(name: &str, runner: Option<Runner>) -> AgentDef {
4880        AgentDef {
4881            name: name.to_string(),
4882            kind: AgentKind::Subprocess,
4883            spec: serde_json::json!({}),
4884            profile: Some(AgentProfile {
4885                system_prompt: "you are a headless worker".to_string(),
4886                model: Some("profile-model".to_string()),
4887                tools: vec!["Read".to_string()],
4888                ..Default::default()
4889            }),
4890            meta: None,
4891            runner,
4892            runner_ref: None,
4893            verdict: None,
4894            lints: None,
4895        }
4896    }
4897
4898    fn echo_def(name: &str) -> SubprocessDef {
4899        SubprocessDef {
4900            name: name.to_string(),
4901            argv: vec!["sh".to_string(), "-c".to_string(), "cat".to_string()],
4902            stdin: Some("{prompt}".to_string()),
4903            env: Default::default(),
4904            cwd: None,
4905            output: None,
4906            stream_mode: None,
4907        }
4908    }
4909
4910    fn bp_with(agents: Vec<AgentDef>, subprocesses: Vec<SubprocessDef>) -> Blueprint {
4911        Blueprint {
4912            schema_version: current_schema_version(),
4913            id: "gh83-ut".into(),
4914            flow: FlowNode::Seq { children: vec![] },
4915            agents,
4916            operators: vec![],
4917            metas: vec![],
4918            hints: Default::default(),
4919            strategy: Default::default(),
4920            metadata: BlueprintMetadata::default(),
4921            spawner_hints: Default::default(),
4922            default_agent_kind: AgentKind::Operator,
4923            default_operator_kind: None,
4924            default_init_ctx: None,
4925            default_agent_ctx: None,
4926            default_context_policy: None,
4927            projection_placement: None,
4928            audits: vec![],
4929            degradation_policy: None,
4930            runners: vec![],
4931            default_runner: None,
4932            subprocesses,
4933            check_policy: None,
4934            blueprint_ref_includes: vec![],
4935        }
4936    }
4937
4938    fn subprocess_runner(template: &str) -> Runner {
4939        Runner::Subprocess {
4940            template: template.to_string(),
4941            overrides: SubprocessOverrides::default(),
4942        }
4943    }
4944
4945    #[test]
4946    fn validate_placeholders_accepts_closed_set_and_json_braces() {
4947        for ok in [
4948            "{system} {system_file} {prompt} {model} {tools_csv} {work_dir} {task_id} {attempt}",
4949            r#"echo '{"result": "ok", "nested": {"a": 1}}'"#,
4950            "no placeholders at all",
4951            "unmatched { brace",
4952        ] {
4953            validate_embed_placeholders(ok, "ut").expect("must be accepted");
4954        }
4955    }
4956
4957    #[test]
4958    fn validate_placeholders_rejects_unknown_token() {
4959        let err = validate_embed_placeholders("--flag {evil}", "argv[1]").unwrap_err();
4960        assert!(err.contains("'{evil}'"), "token named: {err}");
4961        assert!(err.contains("closed set"), "closed set listed: {err}");
4962    }
4963
4964    /// The scan descends into literal braces — a token nested inside a
4965    /// JSON-wrapped template string is still validated (mirrors the
4966    /// spawn-time render scan).
4967    #[test]
4968    fn validate_placeholders_descends_into_literal_braces() {
4969        validate_embed_placeholders(r#"{"task": "{prompt}"}"#, "stdin")
4970            .expect("nested closed-set token must be accepted");
4971        let err = validate_embed_placeholders(r#"{"task": "{evil}"}"#, "stdin").unwrap_err();
4972        assert!(
4973            err.contains("'{evil}'"),
4974            "nested unknown token caught: {err}"
4975        );
4976    }
4977
4978    #[test]
4979    fn hint_resolution_finds_declared_template() {
4980        let agent = subprocess_agent("headless", Some(subprocess_runner("echo")));
4981        let bp = bp_with(vec![agent.clone()], vec![echo_def("echo")]);
4982        let hint = resolve_subprocess_template_hint(&bp, &agent)
4983            .expect("resolves")
4984            .expect("Runner::Subprocess must synthesize a hint");
4985        assert_eq!(hint[SUBPROCESS_TEMPLATE_HINT_KEY]["name"], "echo");
4986        assert!(hint.get(SUBPROCESS_OVERRIDES_HINT_KEY).is_some());
4987    }
4988
4989    #[test]
4990    fn hint_resolution_unknown_template_is_invalid_spec() {
4991        let agent = subprocess_agent("headless", Some(subprocess_runner("nope")));
4992        let bp = bp_with(vec![agent.clone()], vec![echo_def("echo")]);
4993        let err = resolve_subprocess_template_hint(&bp, &agent).unwrap_err();
4994        let msg = format!("{err}");
4995        assert!(msg.contains("'nope'"), "missing template named: {msg}");
4996        assert!(msg.contains("echo"), "defined templates listed: {msg}");
4997    }
4998
4999    #[test]
5000    fn hint_resolution_none_without_subprocess_runner() {
5001        let agent = subprocess_agent("headless", None);
5002        let bp = bp_with(vec![agent.clone()], vec![echo_def("echo")]);
5003        let hint = resolve_subprocess_template_hint(&bp, &agent).expect("resolves");
5004        assert!(hint.is_none(), "spec-based agents keep the historical path");
5005    }
5006
5007    // ─── GH #86: AgentBlock tool-grant hint ───────────────────────────────
5008    //
5009    // Sibling of the `resolve_subprocess_template_hint` cases above; the
5010    // shared `bp_with` / `Blueprint` fixture is why these live in the same
5011    // module rather than a third one.
5012
5013    fn agent_block_agent(name: &str, runner: Option<Runner>, profile_tools: &[&str]) -> AgentDef {
5014        AgentDef {
5015            name: name.to_string(),
5016            kind: AgentKind::AgentBlock,
5017            spec: serde_json::json!({}),
5018            profile: Some(AgentProfile {
5019                system_prompt: "you are an in-process auditor".to_string(),
5020                tools: profile_tools.iter().map(|t| t.to_string()).collect(),
5021                ..Default::default()
5022            }),
5023            meta: None,
5024            runner,
5025            runner_ref: None,
5026            verdict: None,
5027            lints: None,
5028        }
5029    }
5030
5031    fn agent_block_runner(tools: &[&str]) -> Runner {
5032        Runner::AgentBlockInProcess {
5033            tools: tools.iter().map(|t| t.to_string()).collect(),
5034        }
5035    }
5036
5037    /// The AgentBlock tool grant reaches the factory through the
5038    /// `BoundAgent` projection, NOT through a build hint: a declared
5039    /// `Runner::AgentBlockInProcess` overwrites `profile.tools` with its own
5040    /// list. Asserting on the projection is what pins the contract, since a
5041    /// hint for this axis would bypass the pinned snapshot on resume.
5042    #[test]
5043    fn agent_block_runner_tools_are_projected_over_profile_tools() {
5044        let agent = agent_block_agent(
5045            "auditor",
5046            Some(agent_block_runner(&["mcp__outline__list_docs"])),
5047            &["Read"],
5048        );
5049        let bp = bp_with(vec![agent], vec![]);
5050        let bound = resolve_bound_agents(&bp).expect("binds");
5051        let effective = materialize_bound_blueprint(&bp, &bound);
5052        assert_eq!(
5053            effective.agents[0].profile.as_ref().unwrap().tools,
5054            vec!["mcp__outline__list_docs".to_string()],
5055            "the declared Runner tools replace profile.tools (['Read'])"
5056        );
5057    }
5058
5059    /// A declared-but-empty `tools` list is an enforced-empty grant: the
5060    /// projection must still overwrite, or an agent.md's inherited `tools:`
5061    /// line would silently survive a Blueprint that meant to revoke it.
5062    #[test]
5063    fn agent_block_projection_distinguishes_declared_empty_from_absent() {
5064        let declared = agent_block_agent("auditor", Some(agent_block_runner(&[])), &["Read"]);
5065        let bp = bp_with(vec![declared], vec![]);
5066        let bound = resolve_bound_agents(&bp).expect("binds");
5067        let effective = materialize_bound_blueprint(&bp, &bound);
5068        assert!(
5069            effective.agents[0]
5070                .profile
5071                .as_ref()
5072                .unwrap()
5073                .tools
5074                .is_empty(),
5075            "empty means enforced-empty, not 'unset'"
5076        );
5077
5078        let absent = agent_block_agent("auditor", None, &["Read"]);
5079        let bp = bp_with(vec![absent], vec![]);
5080        let bound = resolve_bound_agents(&bp).expect("binds");
5081        let effective = materialize_bound_blueprint(&bp, &bound);
5082        assert_eq!(
5083            effective.agents[0].profile.as_ref().unwrap().tools,
5084            vec!["Read".to_string()],
5085            "no Runner declared → the agent.md tools line stands"
5086        );
5087    }
5088
5089    /// End-to-end through `Compiler::compile`: the projected grant reaches
5090    /// `AgentBlockInProcessSpawnerFactory::build`, whose ScriptBasedAgent
5091    /// guard rejects an unenforceable MCP grant. A successful build returns
5092    /// an opaque `Arc<dyn SpawnerAdapter>`, so this negative path is the
5093    /// compile-level assertion available; the positive paths are covered in
5094    /// `worker::agent_block::runtime`'s tests.
5095    #[test]
5096    fn compile_rejects_script_mode_with_a_declared_mcp_grant() {
5097        let mut agent = agent_block_agent(
5098            "auditor",
5099            Some(agent_block_runner(&["mcp__outline__list_docs"])),
5100            &[],
5101        );
5102        agent.spec = serde_json::json!({ "script_path": "gate.lua" });
5103        let mut bp = bp_with(vec![agent], vec![]);
5104        bp.strategy.strict_refs = false;
5105
5106        let mut registry = SpawnerRegistry::new();
5107        registry.register::<crate::worker::agent_block::AgentBlockInProcessSpawnerFactory>(
5108            Arc::new(crate::worker::agent_block::AgentBlockInProcessSpawnerFactory::new()),
5109        );
5110        // `CompiledBlueprint` is not `Debug`, so `expect_err` is unavailable.
5111        let err = match Compiler::new(registry).compile(&bp) {
5112            Err(e) => e,
5113            Ok(_) => panic!("script mode + declared MCP grant must not compile"),
5114        };
5115        let msg = format!("{err}");
5116        assert!(msg.contains("script_path"), "names the trigger: {msg}");
5117        assert!(
5118            msg.contains("mcp__outline__list_docs"),
5119            "names the unenforceable tools: {msg}"
5120        );
5121    }
5122
5123    /// The guard must not catch a script-mode agent whose tools are all
5124    /// inert (non-`mcp__`) — that shape compiled before the guard existed
5125    /// and grants nothing this backend can enforce either way.
5126    #[test]
5127    fn compile_accepts_script_mode_with_only_inert_tools() {
5128        let mut agent = agent_block_agent("auditor", None, &["Read", "WebSearch"]);
5129        agent.spec = serde_json::json!({ "script_path": "gate.lua" });
5130        let mut bp = bp_with(vec![agent], vec![]);
5131        bp.strategy.strict_refs = false;
5132
5133        let mut registry = SpawnerRegistry::new();
5134        registry.register::<crate::worker::agent_block::AgentBlockInProcessSpawnerFactory>(
5135            Arc::new(crate::worker::agent_block::AgentBlockInProcessSpawnerFactory::new()),
5136        );
5137        if let Err(e) = Compiler::new(registry).compile(&bp) {
5138            panic!("inert tools must not trip the MCP-grant guard: {e}");
5139        }
5140    }
5141
5142    #[test]
5143    fn build_embed_rejects_unknown_placeholder() {
5144        let agent = subprocess_agent("headless", None);
5145        let mut def = echo_def("echo");
5146        def.argv.push("--x={evil}".to_string());
5147        let err = SubprocessProcessSpawnerFactory::build_embed(
5148            &agent,
5149            &serde_json::to_value(&def).unwrap(),
5150            None,
5151        )
5152        .unwrap_err();
5153        assert!(format!("{err}").contains("'{evil}'"));
5154    }
5155
5156    #[test]
5157    fn build_embed_rejects_output_with_stream_mode() {
5158        let agent = subprocess_agent("headless", None);
5159        let mut def = echo_def("echo");
5160        def.stream_mode = Some("ndjson_lines".to_string());
5161        def.output = Some(mlua_swarm_schema::SubprocessOutput {
5162            format: Some("json".to_string()),
5163            result_ptr: None,
5164            ok_from: None,
5165            stats: None,
5166        });
5167        let err = SubprocessProcessSpawnerFactory::build_embed(
5168            &agent,
5169            &serde_json::to_value(&def).unwrap(),
5170            None,
5171        )
5172        .unwrap_err();
5173        assert!(format!("{err}").contains("plain-mode"));
5174    }
5175
5176    #[test]
5177    fn build_embed_rejects_malformed_result_ptr_and_ok_from() {
5178        let agent = subprocess_agent("headless", None);
5179        let mut def = echo_def("echo");
5180        def.output = Some(mlua_swarm_schema::SubprocessOutput {
5181            format: None,
5182            result_ptr: Some("result".to_string()),
5183            ok_from: None,
5184            stats: None,
5185        });
5186        let err = SubprocessProcessSpawnerFactory::build_embed(
5187            &agent,
5188            &serde_json::to_value(&def).unwrap(),
5189            None,
5190        )
5191        .unwrap_err();
5192        assert!(format!("{err}").contains("JSON Pointer"));
5193
5194        let mut def = echo_def("echo");
5195        def.output = Some(mlua_swarm_schema::SubprocessOutput {
5196            format: None,
5197            result_ptr: None,
5198            ok_from: Some("status".to_string()),
5199            stats: None,
5200        });
5201        let err = SubprocessProcessSpawnerFactory::build_embed(
5202            &agent,
5203            &serde_json::to_value(&def).unwrap(),
5204            None,
5205        )
5206        .unwrap_err();
5207        assert!(format!("{err}").contains("exit_code"));
5208    }
5209
5210    #[test]
5211    fn build_embed_bakes_profile_with_override_precedence() {
5212        let agent = subprocess_agent("headless", None);
5213        let def = echo_def("echo");
5214        let overrides = SubprocessOverrides {
5215            model: Some("override-model".to_string()),
5216            tools: vec!["Bash".to_string(), "Write".to_string()],
5217            cwd: Some("/tmp/override-wd".to_string()),
5218        };
5219        let sp = SubprocessProcessSpawnerFactory::build_embed(
5220            &agent,
5221            &serde_json::to_value(&def).unwrap(),
5222            Some(&serde_json::to_value(&overrides).unwrap()),
5223        )
5224        .expect("builds");
5225        let embed = sp.embed.as_ref().expect("embed template baked");
5226        assert_eq!(embed.model.as_deref(), Some("override-model"));
5227        assert_eq!(embed.tools_csv, "Bash,Write");
5228        assert_eq!(embed.cwd.as_deref(), Some("/tmp/override-wd"));
5229        assert_eq!(
5230            embed.system_prompt.as_deref(),
5231            Some("you are a headless worker")
5232        );
5233    }
5234}