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