Skip to main content

mlua_swarm_schema/
lib.rs

1//! Blueprint schema — Swarm IF SoT (= the core type set that defines "how a Blueprint object is written").
2//!
3//! This crate provides **schema types + serde derives only** as a pure IF crate. Execution
4//! layers (SpawnerFactory / EngineDispatcher / Compiler) are not included here; consumers
5//! (the `mlua-swarm` crate) own them. External consumers, sibling worktrees, and
6//! future bundles can read/write Blueprints by depending on this single crate.
7//!
8//! # Versioning contract
9//!
10//! `Blueprint.schema_version` is tied to this crate's semver. It is fixed at 0.1.0 for now;
11//! during 0.x breaking changes are free, and 1.0 will freeze the schema.
12//!
13//! # IN-immutability (extension discipline)
14//!
15//! This crate is the IN side of the swarm layering and stays **plain serde
16//! data**: no compile pass, no field the engine macro-expands, no DSL
17//! dialect. Flow conds are written literally against the Flow.ir Expr set
18//! (`Eq($.<step>.verdict, Lit("blocked"))` — domain verdicts are plain
19//! strings in step output). Authoring sugar (builders) lives OUT on the
20//! consumer side; runtime behavior extension lives in the engine's
21//! `SpawnerLayer` middleware.
22//!
23//! # AgentKind handling (= internal SoT)
24//!
25//! [`AgentKind`] is the SoT for the SpawnerAdapter offering axis. It is a closed enum managed
26//! inside Swarm, extended by variant addition through **explicit maintenance**. String lookup
27//! or a `Custom` escape hatch is deliberately avoided (= structurally eliminates the "silly
28//! runtime typos" class of failures).
29//!
30//! # Examples
31//!
32//! Build a minimal [`Blueprint`] with a single [`AgentDef`] via struct literal:
33//!
34//! ```
35//! use mlua_swarm_schema::{
36//!     AgentDef, AgentKind, Blueprint, current_schema_version,
37//! };
38//! use mlua_flow_ir::{Expr, Node};
39//! use serde_json::json;
40//!
41//! let bp = Blueprint {
42//!     schema_version: current_schema_version(),
43//!     id: "hello".into(),
44//!     flow: Node::Step {
45//!         ref_: "greeter".into(),
46//!         in_: Expr::Lit { value: json!({"name": "world"}) },
47//!         out: Expr::Path { at: "$.greeting".parse().unwrap() },
48//!     },
49//!     agents: vec![AgentDef {
50//!         name: "greeter".into(),
51//!         kind: AgentKind::RustFn,
52//!         spec: json!({"fn_id": "hello_world"}),
53//!         profile: None,
54//!         meta: None,
55//!         runner: None,
56//!         runner_ref: None,
57//!         verdict: None,
58//!     }],
59//!     operators: vec![],
60//!     metas: vec![],
61//!     hints: Default::default(),
62//!     strategy: Default::default(),
63//!     metadata: Default::default(),
64//!     spawner_hints: Default::default(),
65//!     default_agent_kind: AgentKind::Operator,
66//!     default_operator_kind: None,
67//!     default_init_ctx: None,
68//!     default_agent_ctx: None,
69//!     default_context_policy: None,
70//!     projection_placement: None,
71//!     audits: vec![],
72//!     degradation_policy: None,
73//!     runners: vec![],
74//!     default_runner: None,
75//!     check_policy: None,
76//! };
77//!
78//! assert_eq!(bp.id.as_str(), "hello");
79//! assert_eq!(bp.agents.len(), 1);
80//! assert_eq!(bp.strategy.strict_refs, true);
81//! ```
82//!
83//! Round-trip a [`Blueprint`] through JSON (= confirms `serde` derives and the
84//! `deny_unknown_fields` contract):
85//!
86//! ```
87//! use mlua_swarm_schema::{AgentKind, Blueprint, BlueprintMetadata};
88//! use mlua_flow_ir::{Expr, Node};
89//! use serde_json::json;
90//!
91//! let bp = Blueprint {
92//!     schema_version: mlua_swarm_schema::current_schema_version(),
93//!     id: "roundtrip".into(),
94//!     flow: Node::Seq { children: vec![] },
95//!     agents: vec![],
96//!     operators: vec![],
97//!     metas: vec![],
98//!     hints: Default::default(),
99//!     strategy: Default::default(),
100//!     metadata: BlueprintMetadata {
101//!         description: Some("roundtrip smoke".into()),
102//!         default_run_ttl_secs: Some(1800),
103//!         ..Default::default()
104//!     },
105//!     spawner_hints: Default::default(),
106//!     default_agent_kind: AgentKind::Operator,
107//!     default_operator_kind: None,
108//!     default_init_ctx: None,
109//!     default_agent_ctx: None,
110//!     default_context_policy: None,
111//!     projection_placement: None,
112//!     audits: vec![],
113//!     degradation_policy: None,
114//!     runners: vec![],
115//!     default_runner: None,
116//!     check_policy: None,
117//! };
118//!
119//! let json = serde_json::to_string(&bp).unwrap();
120//! let back: Blueprint = serde_json::from_str(&json).unwrap();
121//! assert_eq!(bp, back);
122//! assert_eq!(back.metadata.default_run_ttl_secs, Some(1800));
123//! ```
124
125#![warn(missing_docs)]
126
127use mlua_flow_ir::Node as FlowNode;
128use schemars::JsonSchema;
129use serde::{Deserialize, Serialize};
130use serde_json::Value;
131use std::collections::HashMap;
132
133// ──────────────────────────────────────────────────────────────────────────
134// Versioning
135// ──────────────────────────────────────────────────────────────────────────
136
137/// Current Blueprint schema version. Tied to this crate's semver.
138pub const CURRENT_SCHEMA_VERSION: &str = "0.1.0";
139
140fn default_schema_version() -> semver::Version {
141    current_schema_version()
142}
143
144/// Blueprint construction helper: returns the semver of the current schema version.
145/// Callers can write `schema_version: current_schema_version(),`.
146pub fn current_schema_version() -> semver::Version {
147    semver::Version::parse(CURRENT_SCHEMA_VERSION)
148        .expect("CURRENT_SCHEMA_VERSION must be valid semver")
149}
150
151// ──────────────────────────────────────────────────────────────────────────
152// BlueprintId (human-facing ID newtype)
153// ──────────────────────────────────────────────────────────────────────────
154
155/// Identifier for a Blueprint series — the domain name (`coding`,
156/// `design`, `testing`, etc.). Default: [`BlueprintId::main`].
157///
158/// One representation across the workspace (issue #14): this type is
159/// shared by the schema's [`Blueprint::id`] and the engine's store-layer
160/// keys (`mlua-swarm` re-exports it at the old
161/// `blueprint::store::types::BlueprintId` path). The value is
162/// user-supplied — there is no prefix convention to validate, unlike the
163/// engine's minted `T-` / `R-` / `ST-` ids — so construction is
164/// infallible; the inner string is private so call sites go through
165/// [`BlueprintId::new`] and the accessors. `#[serde(transparent)]` keeps
166/// both the JSON wire shape and the generated JSON Schema a plain string.
167#[derive(
168    Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize, JsonSchema,
169)]
170#[serde(transparent)]
171pub struct BlueprintId(String);
172
173impl BlueprintId {
174    /// The default series name used when a caller doesn't pick one.
175    pub const MAIN: &'static str = "main";
176
177    /// Shorthand for `BlueprintId::new(BlueprintId::MAIN)`.
178    pub fn main() -> Self {
179        Self(Self::MAIN.to_string())
180    }
181
182    /// Wrap any string-like value as a `BlueprintId` (user-supplied key;
183    /// nothing to validate).
184    pub fn new(s: impl Into<String>) -> Self {
185        Self(s.into())
186    }
187
188    /// Borrow the inner series name.
189    pub fn as_str(&self) -> &str {
190        &self.0
191    }
192
193    /// Consume the id and return the inner series name.
194    pub fn into_string(self) -> String {
195        self.0
196    }
197}
198
199impl std::fmt::Display for BlueprintId {
200    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
201        f.write_str(&self.0)
202    }
203}
204
205impl From<String> for BlueprintId {
206    fn from(s: String) -> Self {
207        Self(s)
208    }
209}
210
211impl From<&str> for BlueprintId {
212    fn from(s: &str) -> Self {
213        Self(s.to_string())
214    }
215}
216
217#[cfg(test)]
218mod blueprint_id_tests {
219    use super::*;
220
221    /// issue #14 convergence guard: `Blueprint.id` becoming a newtype must
222    /// not change the generated JSON Schema — the property stays an inline
223    /// plain string (no `$ref`), byte-compatible with the `String` era.
224    #[test]
225    fn blueprint_id_field_schema_stays_a_plain_inline_string() {
226        let schema = schemars::schema_for!(Blueprint);
227        let v = serde_json::to_value(&schema).expect("schema serializes");
228        let id = &v["properties"]["id"];
229        assert_eq!(id["type"], "string", "id must stay a plain string: {id}");
230        assert!(id.get("$ref").is_none(), "id must not become a $ref: {id}");
231    }
232
233    /// The JSON wire shape of the newtype is the bare string.
234    #[test]
235    fn blueprint_id_serde_is_transparent() {
236        let id = BlueprintId::new("coding");
237        assert_eq!(
238            serde_json::to_value(&id).unwrap(),
239            serde_json::json!("coding")
240        );
241        let back: BlueprintId = serde_json::from_value(serde_json::json!("coding")).unwrap();
242        assert_eq!(back, id);
243    }
244}
245
246// ──────────────────────────────────────────────────────────────────────────
247// Blueprint (top-level package)
248// ──────────────────────────────────────────────────────────────────────────
249
250/// Unified package of flow.ir + Swarm extension layers. The entry-point type of Swarm.
251#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, JsonSchema)]
252#[serde(deny_unknown_fields)]
253pub struct Blueprint {
254    /// Schema version (= tied to this crate's semver). Default = `CURRENT_SCHEMA_VERSION`.
255    /// Serialized as a semver string (e.g. `"0.1.0"`).
256    #[serde(default = "default_schema_version")]
257    #[schemars(with = "String")]
258    pub schema_version: semver::Version,
259    /// Blueprint identifier (= unique key within the caller's namespace).
260    #[schemars(with = "String")]
261    pub id: BlueprintId,
262    /// Embeds the flow.ir Node verbatim (= keeps flow.ir side unpolluted).
263    /// Opaque in the JSON Schema (the Node shape is owned by the `mlua-flow-ir`
264    /// crate, a separate repo; see its docs for the Node / Expr grammar).
265    #[schemars(with = "Value")]
266    pub flow: FlowNode,
267    /// Swarm extension layer: agent → backend mapping.
268    #[serde(default)]
269    pub agents: Vec<AgentDef>,
270    /// Swarm extension layer: **design-time definition** of Operator roles (first-class).
271    ///
272    /// `AgentDef.spec.operator_ref` references an `OperatorDef.name` (logical role name) in
273    /// this vec. Embedding runtime-generated IDs such as sid into the BP is forbidden
274    /// (= collapses the design-time vs runtime boundary). Runtime backend bindings are
275    /// established via the attach / register path; the BP side holds only logical names.
276    ///
277    /// Every `kind = Operator` agent must have its `spec.operator_ref` present in this
278    /// list — the compiler validates it at `compile()` time. May be `[]` only when the
279    /// Blueprint declares no Operator agents.
280    #[serde(default)]
281    pub operators: Vec<OperatorDef>,
282    /// GH #21 Phase 2 — named, BP-scoped pool of [`MetaDef`] entries. Two
283    /// independent consumers resolve names against this pool: a
284    /// `$step_meta.ref` envelope embedded in a Step's evaluated `in`
285    /// value (the Step tier — resolved by `EngineDispatcher` in the
286    /// `mlua-swarm` core crate at dispatch time), and
287    /// [`AgentMeta::meta_ref`] (the Agent tier — resolved at launch
288    /// time). The pool lets multiple Steps and/or Agents share one
289    /// declarative context object by name instead of repeating it
290    /// inline. `[]` = no named `MetaDef`s declared (pre-#21-Phase-2
291    /// Blueprints unaffected).
292    #[serde(default, skip_serializing_if = "Vec::is_empty")]
293    pub metas: Vec<MetaDef>,
294    /// Swarm extension layer: per-agent hints (interpreted by the Compiler).
295    #[serde(default)]
296    pub hints: CompilerHints,
297    /// Swarm extension layer: Compiler behavior strategy (strict / lenient).
298    #[serde(default)]
299    pub strategy: CompilerStrategy,
300    /// Blueprint metadata (description / origin / tags / ttl / version label / alias).
301    #[serde(default)]
302    pub metadata: BlueprintMetadata,
303    /// Swarm extension layer: hint keys of the layers to wrap around the SpawnerStack.
304    /// Resolved by the LayerRegistry at engine bind time (= unregistered keys are silently
305    /// skipped). Flow / Blueprint do not hold middleware implementations (e.g. MainAIMiddleware)
306    /// directly; they only declare required capabilities as string keys (= implementations
307    /// live in the engine-side LayerRegistry).
308    #[serde(default)]
309    pub spawner_hints: SpawnerHints,
310    /// BP-wide default `AgentKind` (= fallback when `AgentDef.kind` is omitted).
311    /// Four-layer cascade: (1) Schema impl Default = Operator, (2) CLI
312    /// `--default-agent-kind`, (3) this field (BP JSON literal), (4) `AgentDef.kind`
313    /// (per-agent literal). (5) `CompilerHints.kind_override` allows runtime override.
314    /// All default resolution flows through this path.
315    #[serde(default = "default_global_agent_kind")]
316    pub default_agent_kind: AgentKind,
317    /// BP-wide default `OperatorKind` (= the "BP Global" tier of the 4-tier
318    /// `OperatorKind` cascade). `None` when the Blueprint author does not
319    /// declare a default; the caller-side resolver then falls through to
320    /// the hardcoded `OperatorKind::default()` (Automate).
321    ///
322    /// # 4-tier cascade (highest to lowest priority)
323    ///
324    /// 1. Runtime Agent-level (per-agent override supplied at task-launch time)
325    /// 2. Runtime Global (the launch-time `operator_kind` request)
326    /// 3. BP Agent-level (`OperatorDef.kind`, resolved via `AgentDef.spec.operator_ref`)
327    /// 4. BP Global (this field)
328    /// 5. Default Fallback (`OperatorKind::default()` = Automate)
329    ///
330    /// The collapse itself is implemented once on the engine side and consumed
331    /// per-agent when resolving operator info.
332    #[serde(default, skip_serializing_if = "Option::is_none")]
333    pub default_operator_kind: Option<OperatorKind>,
334    /// Blueprint-level default initial `ctx` for flow-ir eval.
335    /// `TaskLaunchService::launch` shallow-merges this with the
336    /// Task-level `init_ctx` (Task wins on key collision when both
337    /// are `Object`; if Task's `init_ctx` is not an `Object`, it
338    /// full-replaces the default). `None` — no default is merged;
339    /// backward-compat with pre-#19 Blueprints.
340    #[serde(default, skip_serializing_if = "Option::is_none")]
341    #[schemars(with = "Option<Value>")]
342    pub default_init_ctx: Option<Value>,
343    /// GH #21 Phase 1 — "BP Global" tier of the agent-context supply axis:
344    /// a declarative object merged into `ctx.meta.runtime` (and, for
345    /// unnamed keys, `AgentContextView.extra`) targeting every agent's
346    /// runtime materialization. Contrast with [`Self::default_init_ctx`]:
347    /// that field seeds the flow-ir eval `ctx` once at flow start, while
348    /// this one is consumed per-spawn by
349    /// `AgentContextMiddleware`/`AgentContextView` (Contract C, GH #20) —
350    /// a pure flow-ir eval seed vs. an Agent/LLM-boundary runtime default.
351    /// `None` = no BP-global default (pre-#21 Blueprints unaffected).
352    #[serde(default, skip_serializing_if = "Option::is_none")]
353    #[schemars(with = "Option<Value>")]
354    pub default_agent_ctx: Option<Value>,
355    /// GH #21 Phase 1 — "BP Global" tier of the [`ContextPolicy`] cascade:
356    /// the default filter applied to the materialized `AgentContextView`
357    /// when the targeted agent declares no `AgentMeta.context_policy` of
358    /// its own. `None` = pass-all (the pre-#21 behavior).
359    #[serde(default, skip_serializing_if = "Option::is_none")]
360    pub default_context_policy: Option<ContextPolicy>,
361    /// GH #27 (follow-up to #23) — Blueprint-declared override of the
362    /// `mlua-swarm` core crate's projection placement resolver (root
363    /// preference + target directory template for materialized step
364    /// OUTPUT files). `None` = the resolver's byte-compat default (root =
365    /// `work_dir` falling back to `project_root`; dir_template =
366    /// `"workspace/tasks/{task_id}/ctx"`) — every pre-#27 Blueprint is
367    /// unaffected. See [`ProjectionPlacementSpec`]'s doc for field detail.
368    #[serde(default, skip_serializing_if = "Option::is_none")]
369    pub projection_placement: Option<ProjectionPlacementSpec>,
370    /// GH #34 — Blueprint-declared after-run audit hooks: the engine
371    /// auto-kicks each listed [`AuditDef`]'s agent once a matching Step
372    /// settles, and persists its findings as an `OutputEvent::Artifact`
373    /// named `"audit:<step_ref>"` on the AUDITED step's own output tail
374    /// (see `mlua-swarm` core's `AfterRunAuditMiddleware` for the
375    /// dispatch mechanics). `audits[].agent` is validated at
376    /// `Compiler::compile` time against `Blueprint.agents[].name`
377    /// (mirrors the `operator_ref` validation). `[]` (the default) = no
378    /// audit hooks declared — every pre-#34 Blueprint is unaffected,
379    /// byte-for-byte.
380    ///
381    /// **Binding invariant**: an audit's verdict, findings, or even its
382    /// own failure NEVER change the audited step's outcome or gate the
383    /// flow — audits are purely observational.
384    #[serde(default, skip_serializing_if = "Vec::is_empty")]
385    pub audits: Vec<AuditDef>,
386    /// GH #32 — Blueprint-declared policy for worker-reported degradations
387    /// (see `mlua-swarm` core's `RunRecord.degradations` /
388    /// `DegradationEntry`). `None` (the default) is schema-only for now:
389    /// [`DegradationPolicy::Warn`] and [`DegradationPolicy::Fail`] carry the
390    /// same observational behavior at this point — degradations are always
391    /// persisted, never gate the flow. Engine enforcement of `Fail`
392    /// (terminating a Run on any reported degradation) is a follow-up; this
393    /// field only declares author intent today. Every pre-#32 Blueprint is
394    /// unaffected.
395    #[serde(default, skip_serializing_if = "Option::is_none")]
396    pub degradation_policy: Option<DegradationPolicy>,
397    /// GH #46 M2 — named registry of [`RunnerDef`] entries (Tier 1 of the
398    /// 3-tier Worker model: Runner / Agent / Context). Referenced by
399    /// `AgentDef.runner_ref` and [`Self::default_runner`] by name.
400    /// Same registry shape as [`Self::metas`] (GH #21 Phase 2). `[]` (the
401    /// default) = no Runner registry declared — every pre-#46 Blueprint
402    /// is unaffected, byte-for-byte.
403    #[serde(default, skip_serializing_if = "Vec::is_empty")]
404    pub runners: Vec<RunnerDef>,
405    /// GH #46 M2 — the "BP Global" tier of the [`resolve_runner`] cascade:
406    /// a [`RunnerDef::name`] reference into [`Self::runners`] (inline
407    /// `Runner` values are not accepted here — registry names only,
408    /// mirroring [`Self::default_agent_ctx`]'s design). Ranks BELOW an
409    /// agent's own inline `runner` / `runner_ref` / legacy
410    /// `profile.worker_binding` declaration (see [`resolve_runner`]'s
411    /// cascade doc for the full precedence). `None` = no BP-wide default
412    /// declared — every pre-#46 Blueprint is unaffected.
413    #[serde(default, skip_serializing_if = "Option::is_none")]
414    pub default_runner: Option<String>,
415    /// "Blueprint" tier (tier 2) of the `check_policy`
416    /// cascade: `launch request > blueprint > server config` (highest to
417    /// lowest priority). The launch entry point resolves
418    /// `launch.check_policy.or(blueprint.check_policy)` exactly once and
419    /// threads the result into every spawned step's `TaskSpec.check_policy`;
420    /// `None` here (the default) is a no declaration — resolution falls
421    /// through to the launch-request tier and, absent that, to the
422    /// server-wide `EngineCfg.check_policy` default. Every pre-cascade
423    /// Blueprint is unaffected, byte-for-byte. See [`CheckPolicy`] for the
424    /// three fail-open reaction modes.
425    #[serde(default, skip_serializing_if = "Option::is_none")]
426    pub check_policy: Option<CheckPolicy>,
427}
428
429/// How a submit-time projection sink reacts when a fail-open condition
430/// is encountered.
431///
432/// This is the Swarm IF SoT type for the `check_policy` axis; the
433/// `mlua-swarm` core crate re-exports it as `crate::core::config::CheckPolicy`
434/// so every existing path (`EngineCfg.check_policy`, `TaskSpec.check_policy`,
435/// `apply_check_policy`) keeps its old type path unchanged.
436///
437/// Fail-open conditions include: `work_dir` / `project_root` unresolved,
438/// `OutputStore` write error, `FileProjectionAdapter::materialize_submission`
439/// error, and state lookup error. Each call site inside the engine's
440/// `materialize_final_submission` / `materialize_artifact_submission`
441/// currently logs a `tracing::warn!` and returns without materializing the
442/// file / dual-write; `CheckPolicy` is the first-class knob that lets a
443/// caller opt into a different reaction without changing that behaviour by
444/// default.
445///
446/// The three modes are (a) [`CheckPolicy::Silent`] — no log, no error,
447/// operation continues; (b) [`CheckPolicy::Warn`] — log warn (existing
448/// message literal preserved), no error, operation continues (the
449/// default = pre-existing behaviour); (c) [`CheckPolicy::Strict`] — log
450/// the same warn AND return `EngineError::CheckPolicyStrict` (in the core
451/// crate) so the caller can fail the step / launch fast. When Strict
452/// returns an error, the underlying `OutputStore` may already have
453/// appended (dual-write side-effect is not rolled back) — this "state
454/// dirty on fail" semantics is intentional: the append happens **before**
455/// the fail-open branch runs, so Strict surfaces the mismatch instead of
456/// hiding it.
457///
458/// The wire form is snake_case (`"silent"` / `"warn"` / `"strict"`); the
459/// default is [`CheckPolicy::Warn`].
460#[derive(Copy, Clone, Debug, PartialEq, Eq, Default, Serialize, Deserialize, JsonSchema)]
461#[serde(rename_all = "snake_case")]
462pub enum CheckPolicy {
463    /// Skip both the log warn and the error path — completely silent.
464    /// The operation continues (fail-open is still in effect).
465    Silent,
466    /// Log a `tracing::warn!` with the call site's existing message and
467    /// continue (fail-open). Default — byte-identical to the
468    /// pre-`CheckPolicy` behaviour of every submit-time projection sink
469    /// code path.
470    #[default]
471    Warn,
472    /// Log the same warn AND return `EngineError::CheckPolicyStrict` (the
473    /// core crate's error variant). A caller that has opted in can fail the
474    /// step / launch fast instead of proceeding with a partially-realized
475    /// submission. This mode also drives a launch-time pre-dispatch
476    /// validation in `TaskLaunchService::launch` (the `mlua-swarm` core
477    /// crate): a launch whose effective policy resolves to `Strict` and
478    /// that supplies neither `project_root` nor `work_dir` is rejected
479    /// with `TaskLaunchError::PreDispatch` before any step is dispatched,
480    /// rather than dispatching a step that would deterministically hit
481    /// this same error at its first submit-time file materialize.
482    Strict,
483}
484
485/// GH #32 — Blueprint-declared policy for worker-reported degradations. See
486/// [`Blueprint::degradation_policy`] for the (currently schema-only)
487/// enforcement contract.
488#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
489#[serde(rename_all = "snake_case")]
490pub enum DegradationPolicy {
491    /// Observational only (today's only enforced behavior, regardless of
492    /// which variant is declared): degradations are persisted to
493    /// `RunRecord.degradations` and surfaced via `mse_doctor` /
494    /// `GET /v1/runs/:id`, but never change the Run's outcome.
495    Warn,
496    /// Declares intent to terminate the Run on any reported degradation.
497    /// Not yet enforced by the engine — schema-only until the follow-up
498    /// lands.
499    Fail,
500}
501
502/// GH #34 — one Blueprint-declared after-run audit hook. See
503/// [`Blueprint::audits`] for the persistence / invariant contract.
504#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, JsonSchema)]
505#[serde(deny_unknown_fields)]
506pub struct AuditDef {
507    /// Name of the audit agent (must match a [`Blueprint::agents`] entry's
508    /// `name`) the engine dispatches after a matched step settles.
509    /// Validated at `Compiler::compile` time (mirrors
510    /// `AgentDef.spec.operator_ref`'s `operator_ref` validation) — an
511    /// unresolved name rejects compilation.
512    pub agent: String,
513    /// Step names this audit applies to, matched against the step's agent
514    /// ref name. `None`, or a list containing the literal `"*"`, means
515    /// "every step". `Some(vec![])` (an explicit empty list) audits no
516    /// step. `None` is the default.
517    #[serde(default, skip_serializing_if = "Option::is_none")]
518    pub steps: Option<Vec<String>>,
519    /// Dispatch timing for this audit's agent (see [`AuditMode`]).
520    /// Defaults to [`AuditMode::Async`].
521    #[serde(default)]
522    pub mode: AuditMode,
523}
524
525/// GH #34 — dispatch timing for an [`AuditDef`]'s audit agent. Neither
526/// variant ever changes the audited step's outcome (see
527/// [`Blueprint::audits`]'s binding invariant).
528#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize, JsonSchema)]
529#[serde(rename_all = "snake_case")]
530pub enum AuditMode {
531    /// Fire-and-forget: the audit runs in the background after the
532    /// audited step settles; the audited step's own spawn signal returns
533    /// immediately, without waiting for the audit to finish.
534    #[default]
535    Async,
536    /// Awaited before the audited step's spawn signal is returned to the
537    /// engine — still never alters that signal or the step's recorded
538    /// outcome.
539    Sync,
540}
541
542/// Receptacle for a Blueprint-driven filter over the materialized
543/// `AgentContextView` (GH #20/#21). Declared BP-side via
544/// [`Blueprint::default_context_policy`] (BP-global) or
545/// `AgentMeta::context_policy` (per-agent, outranks the BP-global tier) —
546/// resolved and applied by `AgentContextMiddleware` in the `mlua-swarm`
547/// core crate (this crate stays execution-free; see the crate doc).
548/// Default (`include: None, exclude: vec![]`) is pass-all — [`Self::allows`]
549/// returns `true` for every field name.
550#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, JsonSchema)]
551#[serde(deny_unknown_fields)]
552pub struct ContextPolicy {
553    /// Field names to keep. `None` means "keep everything" (pass-all).
554    /// Matched against the `AgentContextView` named-field strings
555    /// (`"project_root"` / `"work_dir"` / `"task_metadata"` / `"run_id"` /
556    /// `"project_name_alias"`) and `extra` keys by their own key string.
557    /// Identity fields (`task_id` / `agent` / `attempt`) are never
558    /// filtered regardless of `include`.
559    #[serde(default)]
560    pub include: Option<Vec<String>>,
561    /// Field names to drop, applied AFTER `include` (exclude wins when a
562    /// name appears in both). Same name-matching rule as `include`.
563    #[serde(default)]
564    pub exclude: Vec<String>,
565    /// Which preceding steps' OUTPUT pointers a worker's fetch payload may
566    /// see (`WorkerPayload.context.steps`, ST5 of the `projection-adapter`
567    /// design). `None` = pass-all (every submitted step, the pre-ST5
568    /// `ctx_step_dir` behavior); `Some(list)` = only the named steps;
569    /// `Some(vec![])` = none. Evaluated by [`Self::allows_step`], a sibling
570    /// of [`Self::allows`] with the same include/exclude precedence rule
571    /// but a separate namespace (step names vs. `AgentContextView` field /
572    /// `extra` key names never collide).
573    #[serde(default)]
574    pub steps: Option<Vec<String>>,
575    /// Step names to drop, applied AFTER `steps` (exclude wins when a name
576    /// appears in both). Same name-matching rule as `steps`.
577    #[serde(default)]
578    pub steps_exclude: Vec<String>,
579}
580
581impl ContextPolicy {
582    /// Whether `name` survives this policy: `false` if `exclude` lists it;
583    /// otherwise `true` when `include` is `None` (pass-all) or lists
584    /// `name`. Shared by both the schema crate (tests) and the `mlua-swarm`
585    /// core crate's `AgentContextView::apply_policy`, so the include/exclude
586    /// evaluation rule has exactly one implementation.
587    pub fn allows(&self, name: &str) -> bool {
588        if self.exclude.iter().any(|excluded| excluded == name) {
589            return false;
590        }
591        match &self.include {
592            Some(list) => list.iter().any(|included| included == name),
593            None => true,
594        }
595    }
596
597    /// Whether the preceding step named `name` survives this policy for the
598    /// worker fetch payload's `context.steps` pointer list: `false` if
599    /// `steps_exclude` lists it; otherwise `true` when `steps` is `None`
600    /// (pass-all) or lists `name`. Same precedence rule as [`Self::allows`],
601    /// evaluated against the separate `steps` / `steps_exclude` fields.
602    pub fn allows_step(&self, name: &str) -> bool {
603        if self.steps_exclude.iter().any(|excluded| excluded == name) {
604            return false;
605        }
606        match &self.steps {
607            Some(list) => list.iter().any(|included| included == name),
608            None => true,
609        }
610    }
611}
612
613/// Global default `AgentKind` at the Schema impl Default layer. Bottom of the 4-layer cascade.
614pub fn default_global_agent_kind() -> AgentKind {
615    AgentKind::Operator
616}
617
618/// Set of **capability hint keys** for the SpawnerLayer required by a Blueprint.
619///
620/// # Design rationale (= for the person who will reconstruct this later)
621///
622/// A Blueprint is a pure layer of flow.ir + agent name binding and holds no middleware
623/// **implementation**. Nevertheless there are cases where the caller must be told the BP
624/// needs certain **capabilities** — e.g. "MainAI hook required", "Operator delegate path
625/// required", operator role mode switching, presence/absence of senior escalation, and
626/// so on.
627///
628/// `spawner_hints.layers` is the place where those capabilities are declared as **string
629/// keys**. The engine-side `LayerRegistry` (= consumer crate) resolves key → factory and
630/// wraps the compiled routes with a `SpawnerStack`. The Blueprint does not import the
631/// concrete `MainAIMiddleware` type; it exposes intent through strings such as `"main_ai"`
632/// (= separates the pure Flow layer from implementation details).
633///
634/// # Canonical hint keys
635///
636/// - `"main_ai"` → `MainAIMiddleware` (= fires SpawnHook before/after when kind is MainAi/Composite)
637/// - `"senior_escalation"` → `SeniorEscalationMiddleware` (= fires SeniorBridge.ask on worker ok=false)
638/// - `"operator_delegate"` → `OperatorDelegateMiddleware` (= delegates the entire spawn to an external Operator.execute)
639///
640/// # Behavior of unregistered keys
641///
642/// If the engine-side LayerRegistry has no matching factory, the key is **silently skipped**
643/// (= lenient default). This preserves Blueprint portability (= an unsupported capability in
644/// another deployment falls back gracefully).
645#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, JsonSchema)]
646#[serde(deny_unknown_fields)]
647pub struct SpawnerHints {
648    /// Ordered list of layer hint keys to wrap around the SpawnerStack.
649    #[serde(default)]
650    pub layers: Vec<String>,
651}
652
653// ──────────────────────────────────────────────────────────────────────────
654// AgentDef / AgentKind / AgentProfile / AgentMeta
655// ──────────────────────────────────────────────────────────────────────────
656
657/// Maps an agent name to a Worker IMPL kind and its configuration. Referenced from flow.ir
658/// `Step.ref` by name.
659///
660/// # Design
661///
662/// `AgentDef.kind` directly expresses the **Worker IMPL axis** (= not the old Spawner axis).
663/// Dispatching to a host Spawner adapter (`InProcSpawner` / `ProcessSpawner` /
664/// `OperatorSpawner`) is done by an internal Resolver on the compiler side. The design goal
665/// is "do not make the caller aware of which Spawner hosts the Worker IMPL"; the caller
666/// (Blueprint author) sees only the WorkerIMPL viewpoint.
667///
668/// A Spawner-axis hint (= "which adapter would you prefer running this Worker on", as a
669/// priority list) will be added via a future `spawner_hint: Vec<Spawner>` field as a carry.
670/// The current internal Resolver is a fixed 1:1 mapping, so the field is unnecessary today.
671#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, JsonSchema)]
672#[serde(deny_unknown_fields)]
673pub struct AgentDef {
674    /// Agent name (= referenced from flow.ir `Step.ref`).
675    pub name: String,
676    /// Worker IMPL kind (= see [`AgentKind`]).
677    pub kind: AgentKind,
678    /// Free-form schema per kind. Interpreted by the SpawnerFactory.
679    #[serde(default)]
680    pub spec: Value,
681    /// Agent persona information (system_prompt / model / tools, etc.). Orthogonal to the
682    /// backend kind and is a first-class field. Expected to be populated by
683    /// `agent_md_loader` from the frontmatter + body of an `agent.md`. `None` = an agent
684    /// without a profile (= backend built solely from `spec`).
685    #[serde(default)]
686    pub profile: Option<AgentProfile>,
687    /// Agent-level metadata (description / version / tags).
688    #[serde(default)]
689    pub meta: Option<AgentMeta>,
690    /// GH #46 M2 — inline [`Runner`] declaration: the highest-priority
691    /// tier of the [`resolve_runner`] cascade. `None` = this agent
692    /// declares no inline Runner (falls through to [`Self::runner_ref`],
693    /// then the legacy `profile.worker_binding` fallback, then
694    /// `Blueprint.default_runner`).
695    #[serde(default, skip_serializing_if = "Option::is_none")]
696    pub runner: Option<Runner>,
697    /// GH #46 M2 — a [`RunnerDef::name`] reference into
698    /// `Blueprint.runners` (second-priority tier of [`resolve_runner`]).
699    /// `None` = this agent declares no Runner registry reference.
700    #[serde(default, skip_serializing_if = "Option::is_none")]
701    pub runner_ref: Option<String>,
702    /// GH #50 — opt-in declaration of which OUTPUT channel this agent's
703    /// verdict token lives on, and the closed set of tokens it may emit
704    /// through that channel (see [`VerdictContract`]). Consumed by the
705    /// `mlua-swarm` core crate's `Compiler::compile` to lint
706    /// `Branch`/`Loop` `Eq`/`Ne`/`In` conds against this agent's output at
707    /// register time; a follow-up submit-time producer gate is a separate
708    /// enforcement point. `None` (the default) — this agent declares no
709    /// contract; a cond comparing its output to a literal is unchanged (at
710    /// most a `tracing::warn!`, never rejected) — every pre-GH-#50
711    /// Blueprint is unaffected, byte-for-byte.
712    #[serde(default, skip_serializing_if = "Option::is_none")]
713    pub verdict: Option<VerdictContract>,
714}
715
716/// Agent persona information. Orthogonal to the backend kind (Shell / InProc / Operator).
717///
718/// Populated by `agent_md_loader::load_dir` from the frontmatter and Markdown body of
719/// `agents/*.md` in agent-profiles. The backend (e.g. AgentBlockOperator) receives this
720/// struct at construction / dispatch time and consumes `system_prompt` as the LLM API
721/// system message and `model` / `tools` as configuration.
722///
723/// C-C-specific fields (`permissionMode` / `memory` / `abtest`, etc.) are dumped into
724/// `extras: Value`, and consumers that need them read them out. This is the escape hatch
725/// that keeps the schema future-proof rather than making it strict.
726#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default, JsonSchema)]
727#[serde(deny_unknown_fields)]
728pub struct AgentProfile {
729    /// Markdown body (= system prompt content).
730    #[serde(default)]
731    pub system_prompt: String,
732    /// LLM model identifier (e.g. `"sonnet"` / `"haiku"` / `"opus"`).
733    #[serde(default)]
734    pub model: Option<String>,
735    /// Reasoning effort (e.g. `"low"` / `"medium"` / `"high"`).
736    #[serde(default)]
737    pub effort: Option<String>,
738    /// List of available tool names (normalized from the CSV form in frontmatter).
739    #[serde(default)]
740    pub tools: Vec<String>,
741    /// Frontmatter `description`. A short one-line description.
742    #[serde(default)]
743    pub description: Option<String>,
744    /// C-C-specific / future-proof fields (permissionMode / memory / abtest / ...).
745    /// Shape is the leftover keys of the agent.md frontmatter dumped as a JSON object.
746    #[serde(default)]
747    pub extras: Value,
748    /// Content hash (blake3 32-byte hex) of the agent body (= `system_prompt`).
749    ///
750    /// # Purpose
751    ///
752    /// When the Enhance loop receives a Patch that replaces
753    /// `/agents/N/profile/system_prompt`, the post-hook in `patch_applier.lua`
754    /// recomputes this field (= new blake3 of the body) and updates it automatically.
755    /// This is the field that structurally prevents a Blueprint carrying a stale hash
756    /// from being committed.
757    ///
758    /// - `None` = hash not computed (= manually built agent, or a Blueprint predating this field)
759    /// - `Some(hex)` = latest hash at agent-profiles seed time or after PatchApplier
760    ///
761    /// Planned to be used as the cache-index key in `AgentStore`.
762    #[serde(default)]
763    pub version_hash: Option<String>,
764    /// Claude Code SubAgent definition name this agent binds to at spawn
765    /// time (e.g. "mse-worker-coder"). Why: the Blueprint is the single
766    /// source of truth for the declaration↔executor binding — an external
767    /// registry would duplicate what `tools` already declares and drift.
768    /// `None` is valid for agents whose operator backend never dispatches
769    /// a SubAgent (direct-LLM operators); WS thin-path operators require
770    /// it at compile time (see `Operator::requires_worker_binding`).
771    #[serde(default, skip_serializing_if = "Option::is_none")]
772    pub worker_binding: Option<String>,
773}
774
775/// SoT of the **Worker IMPL axis**. A closed enum managed inside Swarm and extended by
776/// variant addition through **explicit maintenance**. String lookup / escape hatches are
777/// deliberately not adopted.
778///
779/// This enum **expresses Worker IMPL directly**; dispatching to a host Spawner adapter is
780/// resolved by an internal Resolver on the compiler side (= callers see only the Worker
781/// IMPL viewpoint).
782///
783/// # Internal Resolver mapping (= currently a fixed 1:1, carry: priority list form)
784///
785/// | AgentKind | Host Spawner adapter |
786/// |---|---|
787/// | `Lua` | `InProcSpawner` (mlua VM eval) |
788/// | `RustFn` | `InProcSpawner` (Rust closure) |
789/// | `AgentBlock` | `InProcSpawner` (agent-block-core SDK in-process) |
790/// | `Subprocess` | `ProcessSpawner` (child process launch) |
791/// | `Operator` | `OperatorSpawner` (interactive role / Human-MainAI delegation) |
792#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash, JsonSchema)]
793#[serde(rename_all = "snake_case")]
794pub enum AgentKind {
795    /// Lua script eval through the mlua VM (= factory-side registry looked up by `spec.fn_id`).
796    Lua,
797    /// Rust closure (= factory-side registry looked up by `spec.fn_id`).
798    RustFn,
799    /// Headless LLM agent via the agent-block-core SDK (in-process).
800    AgentBlock,
801    /// Child-process launch (= `spec.program` + `args`, via the ProcessSpawner path).
802    Subprocess,
803    /// Interactive Operator role (= MainAI / Human delegation, `spec.operator_ref`).
804    Operator,
805}
806
807// ──────────────────────────────────────────────────────────────────────────
808// VerdictContract / VerdictChannel (GH #50 — opt-in cond↔output-shape lint)
809// ──────────────────────────────────────────────────────────────────────────
810
811/// Opt-in per-agent declaration of the step OUTPUT shape a downstream
812/// `Branch`/`Loop` `cond` is allowed to structurally compare against — see
813/// the `blueprint-authoring.md` guide's "Returning verdicts to drive BP
814/// flow" section for the Pattern A/B shapes this mirrors. Consumed by the
815/// `mlua-swarm` core crate's `Compiler::compile` (a register-time,
816/// read-only lint over `Branch`/`Loop` `Eq`/`Ne`/`In` conds — no `flow`
817/// rewriting, no new `Expr` forms) and, as a follow-up, by the server's
818/// submit-time producer gate. `None` on [`AgentDef::verdict`] (the
819/// default) means neither enforcement point runs for that agent — the
820/// pre-GH-#50 behavior, byte-for-byte.
821#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, JsonSchema)]
822#[serde(deny_unknown_fields)]
823pub struct VerdictContract {
824    /// Which OUTPUT channel carries the verdict token — see
825    /// [`VerdictChannel`].
826    pub channel: VerdictChannel,
827    /// Closed set of the verdict tokens this agent may emit through the
828    /// declared `channel` (e.g. `["PASS", "BLOCKED"]`). A `Branch`/`Loop`
829    /// cond's `Lit` operand(s) compared against this agent's declared
830    /// channel must be members of this set.
831    pub values: Vec<String>,
832}
833
834/// Which step OUTPUT channel a [`VerdictContract`] addresses — the two
835/// canonical submit shapes documented in the `blueprint-authoring.md`
836/// guide's "Returning verdicts to drive BP flow" section.
837#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
838#[serde(rename_all = "lowercase")]
839pub enum VerdictChannel {
840    /// Pattern A — the plain step OUTPUT body IS the verdict scalar; a cond
841    /// addresses it as the bare step output (`$.<step>`).
842    Body,
843    /// Pattern B — the verdict is staged as the named part `"verdict"`
844    /// alongside a separate plain-body report; a cond addresses it as
845    /// `$.<step>.parts.verdict` (equivalently `$.<step>.parts["verdict"]`
846    /// — both forms normalize to the same canonical [`Path`](mlua_flow_ir::Path) `Display`).
847    Part,
848}
849
850// ──────────────────────────────────────────────────────────────────────────
851// Runner / RunnerDef / WorkerModel / resolve_runner (GH #46 Milestone 2)
852// ──────────────────────────────────────────────────────────────────────────
853
854/// The execution shell an agent's Worker IMPL runs inside — holding tool
855/// grant, model selection, and runtime capabilities. Tier 1 of the GH #46
856/// 3-tier Worker model (Runner / Agent / Context).
857///
858/// Runner here is broader than the ADK / OpenAI Agents SDK Runner (a loop
859/// driver): it is the execution shell holding tool grant, model
860/// selection, and runtime capabilities. Loop driving itself is the
861/// backend's job (Claude Code harness / AgentBlock runtime).
862///
863/// Resolved per-agent by [`resolve_runner`]'s 5-step cascade; wiring the
864/// resolved value into the launch path is Milestone 3 — this Milestone
865/// only declares the shape and the pure resolver.
866#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
867#[serde(tag = "backend", rename_all = "snake_case", deny_unknown_fields)]
868pub enum Runner {
869    /// WS backend: Claude Code subagent wrapper. `variant` is the
870    /// wrapper's subagent_type; `tools` mirrors the wrapper frontmatter =
871    /// enforced grant.
872    WsClaudeCode {
873        /// The wrapper's `subagent_type` (= `WorkerBinding.variant` in the
874        /// `mlua-swarm` core crate).
875        variant: String,
876        /// Declared (informational) tool list — mirrors the wrapper
877        /// frontmatter; the actual grant is enforced by the wrapper file
878        /// itself, not by this list.
879        #[serde(default, skip_serializing_if = "Vec::is_empty")]
880        tools: Vec<String>,
881    },
882    /// In-process backend: agent-block runtime. `tools` is the effective
883    /// (enforced) tool set for the in-process registry.
884    AgentBlockInProcess {
885        /// Effective (enforced) tool set passed to the agent-block
886        /// runtime's registry — unlike `WsClaudeCode::tools`, this list is
887        /// not merely informational.
888        #[serde(default, skip_serializing_if = "Vec::is_empty")]
889        tools: Vec<String>,
890    },
891}
892
893/// One [`Blueprint::runners`] registry entry — a named [`Runner`]
894/// declaration referenced by `AgentDef.runner_ref` /
895/// [`Blueprint::default_runner`]. Same registry shape as [`MetaDef`] (GH
896/// #21 Phase 2).
897#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
898#[serde(deny_unknown_fields)]
899pub struct RunnerDef {
900    /// Registry key, referenced by `AgentDef.runner_ref` /
901    /// `Blueprint.default_runner`.
902    pub name: String,
903    /// The declared Runner.
904    pub runner: Runner,
905}
906
907/// Canonical GH #46 Worker unit: a resolved [`Runner`] paired with the
908/// [`AgentDef`] it backs. The Milestone 4 adapter is the consumer that
909/// turns this into a runtime spawn; this crate only declares the shape
910/// (no execution logic lives here — see the crate doc's IN-immutability
911/// discipline).
912#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
913#[serde(deny_unknown_fields)]
914pub struct WorkerModel {
915    /// The resolved Runner.
916    pub runner: Runner,
917    /// The agent this Runner backs.
918    pub agent: AgentDef,
919}
920
921/// Everything [`resolve_runner`] can fail with: an `AgentDef.runner_ref`
922/// / `Blueprint.default_runner` reference that names no entry in
923/// `Blueprint.runners`.
924#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
925pub enum RunnerResolveError {
926    /// `AgentDef.runner_ref` names a [`RunnerDef::name`] absent from
927    /// `Blueprint.runners`.
928    #[error(
929        "agent '{agent}' runner_ref '{ref_name}' does not match any RunnerDef.name in \
930         Blueprint.runners (defined: {available:?})"
931    )]
932    UnknownRunnerRef {
933        /// The agent whose `runner_ref` didn't resolve.
934        agent: String,
935        /// The `runner_ref` value that was looked up.
936        ref_name: String,
937        /// The `RunnerDef.name`s that *are* declared, for the error message.
938        available: Vec<String>,
939    },
940    /// `Blueprint.default_runner` names a [`RunnerDef::name`] absent from
941    /// `Blueprint.runners`.
942    #[error(
943        "default_runner '{ref_name}' does not match any RunnerDef.name in Blueprint.runners \
944         (defined: {available:?})"
945    )]
946    UnknownDefaultRunner {
947        /// The `default_runner` value that was looked up.
948        ref_name: String,
949        /// The `RunnerDef.name`s that *are* declared, for the error message.
950        available: Vec<String>,
951    },
952}
953
954/// Resolve `agent`'s effective [`Runner`] against `bp`, in cascade order
955/// (highest priority first):
956///
957/// 1. `agent.runner` (inline declaration) — wins unconditionally.
958/// 2. `agent.runner_ref`, resolved against `bp.runners` (an unresolved
959///    name is [`RunnerResolveError::UnknownRunnerRef`]).
960/// 3. Legacy fallback (agent-level): `agent.profile.worker_binding =
961///    Some(variant)` becomes `Runner::WsClaudeCode { variant,
962///    tools: profile.tools.clone() }` — the same synthesis
963///    `crate::service::task_launch::derive_worker_bindings` (in the
964///    `mlua-swarm` core crate) performs at launch time today.
965/// 4. `bp.default_runner`, resolved against `bp.runners` (an unresolved
966///    name is [`RunnerResolveError::UnknownDefaultRunner`]).
967/// 5. `Ok(None)` — no Runner declared through any tier.
968///
969/// **Legacy (agent-level) beats `default_runner` (BP-global)**: tier 3
970/// outranks tier 4, the same "agent-level wins over BP-global" rule the
971/// ctx cascade (`AgentInline > MetaRef > BpGlobal`, see
972/// `mlua-swarm`'s `core::explain::CtxTier`) already follows.
973///
974/// Pure and read-only: this Milestone does not wire the result into the
975/// launch / compile path (Milestone 3 scope) — it only declares the
976/// resolver.
977pub fn resolve_runner(
978    bp: &Blueprint,
979    agent: &AgentDef,
980) -> Result<Option<Runner>, RunnerResolveError> {
981    // 1. inline — wins unconditionally.
982    if let Some(runner) = &agent.runner {
983        return Ok(Some(runner.clone()));
984    }
985
986    // 2. runner_ref → bp.runners lookup.
987    if let Some(ref_name) = &agent.runner_ref {
988        return match bp.runners.iter().find(|def| &def.name == ref_name) {
989            Some(def) => Ok(Some(def.runner.clone())),
990            None => Err(RunnerResolveError::UnknownRunnerRef {
991                agent: agent.name.clone(),
992                ref_name: ref_name.clone(),
993                available: bp.runners.iter().map(|d| d.name.clone()).collect(),
994            }),
995        };
996    }
997
998    // 3. legacy fallback (agent-level `profile.worker_binding`) — outranks
999    // `bp.default_runner` (tier 4).
1000    if let Some(variant) = agent
1001        .profile
1002        .as_ref()
1003        .and_then(|p| p.worker_binding.as_ref())
1004    {
1005        let tools = agent
1006            .profile
1007            .as_ref()
1008            .map(|p| p.tools.clone())
1009            .unwrap_or_default();
1010        return Ok(Some(Runner::WsClaudeCode {
1011            variant: variant.clone(),
1012            tools,
1013        }));
1014    }
1015
1016    // 4. bp.default_runner → bp.runners lookup.
1017    if let Some(ref_name) = &bp.default_runner {
1018        return match bp.runners.iter().find(|def| &def.name == ref_name) {
1019            Some(def) => Ok(Some(def.runner.clone())),
1020            None => Err(RunnerResolveError::UnknownDefaultRunner {
1021                ref_name: ref_name.clone(),
1022                available: bp.runners.iter().map(|d| d.name.clone()).collect(),
1023            }),
1024        };
1025    }
1026
1027    // 5. nothing declared through any tier.
1028    Ok(None)
1029}
1030
1031// ──────────────────────────────────────────────────────────────────────────
1032// OperatorDef / OperatorKind
1033// ──────────────────────────────────────────────────────────────────────────
1034
1035/// Kind axis of an Operator role (= "in which mode does this Operator run").
1036/// Corresponds 1:1 with the engine's runtime `OperatorKind`. Kept as a schema
1037/// duplicate so that BPs can be authored while depending only on this crate.
1038#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default, JsonSchema)]
1039#[serde(rename_all = "snake_case")]
1040pub enum OperatorKind {
1041    /// MainAI (= interactive AI Operator via WS client or SDK).
1042    MainAi,
1043    /// Automate (= normal spawn path, without human interception).
1044    #[default]
1045    Automate,
1046    /// Composite (= MainAi + Automate running side by side).
1047    Composite,
1048}
1049
1050/// Design-time definition of an Operator role (first-class).
1051///
1052/// `AgentDef.spec.operator_ref` references this struct's `name` as a logical role name.
1053/// Binding to a runtime backend (WS session / SDK / pool, etc.) is established via the
1054/// attach path; the BP side only declares "under this logical name we expect an Operator
1055/// of this Kind".
1056///
1057/// `spec` is an escape hatch for kind-specific config (WS endpoint / SDK profile / pool
1058/// binding, etc.). Even when empty, declaring `name` + `kind` alone is enough for
1059/// compile-time validation to succeed (= it guarantees that agent `operator_ref` values
1060/// reference an existing definition).
1061#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, JsonSchema)]
1062#[serde(deny_unknown_fields)]
1063pub struct OperatorDef {
1064    /// Logical role name (= design-time symbol referenced from `AgentDef.spec.operator_ref`).
1065    pub name: String,
1066    /// Display name for UI / docs (optional).
1067    #[serde(default)]
1068    pub display_name: Option<String>,
1069    /// Kind axis of the Operator (MainAi / Automate / Composite) — the "BP
1070    /// Agent-level" tier of the 4-tier `OperatorKind` cascade (see
1071    /// `Blueprint.default_operator_kind` for the full tier list). `None`
1072    /// when this `OperatorDef` does not declare a kind; the resolver then
1073    /// falls through to BP Global / Default Fallback for agents referencing
1074    /// this role via `AgentDef.spec.operator_ref`.
1075    #[serde(default)]
1076    pub kind: Option<OperatorKind>,
1077    /// Kind-specific config (WS endpoint / SDK profile / pool binding, etc.). Interpreted
1078    /// by the factory.
1079    #[serde(default)]
1080    pub spec: Value,
1081    /// Operator persona information (e.g. system_prompt template). Same shape as
1082    /// `AgentDef.profile`. Used as a template when the Operator itself plays a "role".
1083    /// If `None`, the agent-side profile is used instead.
1084    #[serde(default)]
1085    pub profile: Option<AgentProfile>,
1086    /// Operator-level metadata (description / version / tags).
1087    #[serde(default)]
1088    pub meta: Option<AgentMeta>,
1089}
1090
1091/// Named, multi-step-shared declarative context payload (GH #21 Phase 2).
1092///
1093/// Lives in the [`Blueprint::metas`] pool and is referenced by name from
1094/// two independent consumers: a `$step_meta.ref` envelope embedded in a
1095/// Step's evaluated `in` value (the Step tier, resolved by
1096/// `EngineDispatcher::dispatch` in the `mlua-swarm` core crate at
1097/// dispatch time — see `EngineDispatcher::with_step_metas`), and
1098/// [`AgentMeta::meta_ref`] (the Agent tier, resolved at launch time and
1099/// merged UNDER the agent's inline `AgentMeta::ctx`). The pool lets
1100/// multiple Steps and/or Agents share one declarative context object by
1101/// name instead of repeating it inline.
1102#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, JsonSchema)]
1103#[serde(deny_unknown_fields)]
1104pub struct MetaDef {
1105    /// Logical name (= referenced by `$step_meta.ref` and
1106    /// `AgentMeta.meta_ref`; unique within [`Blueprint::metas`]).
1107    pub name: String,
1108    /// Declarative context payload. Consumers expect a JSON `Object` so
1109    /// it can be shallow-merged with an `inline` override / an agent's
1110    /// own `ctx` (a non-`Object` value is rejected — loudly at dispatch
1111    /// time for the Step tier, defensively (warn + skip) at launch time
1112    /// for the Agent tier); the shape is otherwise free-form.
1113    pub ctx: Value,
1114}
1115
1116/// GH #27 (follow-up to #23) — Blueprint-declared override of the
1117/// `mlua-swarm` core crate's placement resolver
1118/// (`mlua_swarm::core::projection_placement::ProjectionPlacement`), which
1119/// decides where a Step's materialized OUTPUT file (submit-time sink,
1120/// server read-back, and spawn-time `ctx_projection` pointer — the "3
1121/// path" convergence point) is written on disk. Both fields are
1122/// independently optional and validated (`dir_template`) at
1123/// `Compiler::compile` time — see that resolver's `from_spec` doc for the
1124/// full rejection rules.
1125#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, JsonSchema)]
1126#[serde(deny_unknown_fields)]
1127pub struct ProjectionPlacementSpec {
1128    /// Which of the spawn-time `work_dir` / `project_root` to prefer as
1129    /// the materialize root, falling back to the other when the
1130    /// preferred one is absent. `"work_dir"` (default, current
1131    /// byte-compat behavior) | `"project_root"`. `None` = the default
1132    /// (`"work_dir"`).
1133    #[serde(default, skip_serializing_if = "Option::is_none")]
1134    pub root: Option<String>,
1135    /// Target directory template, relative to the resolved root, with a
1136    /// `{task_id}` placeholder substituted at materialize time. `None` =
1137    /// the default (`"workspace/tasks/{task_id}/ctx"`, current byte-compat
1138    /// behavior). Must be non-empty, contain the `{task_id}` placeholder,
1139    /// stay relative, and not contain any `..` path segment — rejected at
1140    /// `Compiler::compile` time otherwise.
1141    #[serde(default, skip_serializing_if = "Option::is_none")]
1142    pub dir_template: Option<String>,
1143}
1144
1145/// Agent / Operator level metadata (description / version / tags).
1146#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default, JsonSchema)]
1147#[serde(deny_unknown_fields)]
1148pub struct AgentMeta {
1149    /// Short human-readable description.
1150    #[serde(default)]
1151    pub description: Option<String>,
1152    /// Free-form version label.
1153    #[serde(default)]
1154    pub version: Option<String>,
1155    /// Tag list for classification / routing.
1156    #[serde(default)]
1157    pub tags: Vec<String>,
1158    /// GH #21 Phase 1 — "BP Agent-level" tier of the agent-context supply
1159    /// axis: a declarative object merged into `ctx.meta.runtime` for this
1160    /// agent's spawns, on top of (and winning over)
1161    /// [`Blueprint::default_agent_ctx`]. See that field's doc for the
1162    /// contrast with `default_init_ctx`. `None` = this agent declares no
1163    /// per-agent context (the BP-global tier alone applies, if any).
1164    #[serde(default, skip_serializing_if = "Option::is_none")]
1165    #[schemars(with = "Option<Value>")]
1166    pub ctx: Option<Value>,
1167    /// GH #21 Phase 1 — "BP Agent-level" tier of the [`ContextPolicy`]
1168    /// cascade: outranks [`Blueprint::default_context_policy`] for this
1169    /// agent. `None` = fall through to the BP-global policy (or pass-all
1170    /// if that is also `None`).
1171    #[serde(default, skip_serializing_if = "Option::is_none")]
1172    pub context_policy: Option<ContextPolicy>,
1173    /// GH #21 Phase 2 — "BP Agent-level" tier of the [`MetaDef`] pool:
1174    /// resolves against [`Blueprint::metas`] by name. The resolved
1175    /// `ctx` sits UNDER this agent's inline [`Self::ctx`] (inline wins
1176    /// on key collision). `None` = this agent declares no shared
1177    /// `MetaDef` reference.
1178    #[serde(default, skip_serializing_if = "Option::is_none")]
1179    pub meta_ref: Option<String>,
1180    /// GH #23 — the step-projection canonical name this agent's dispatched
1181    /// Steps should be addressed by (data-plane submit / `ContextPolicy`
1182    /// filter / `StepPointer`/`StepSummary` `name` / REST `:step` path /
1183    /// materialized file stem — see `mlua-swarm` core's
1184    /// `core::step_naming::StepNaming` for the table this field feeds).
1185    /// `None` = this agent declares no projection name; the canonical
1186    /// name falls back to the Step's `ref` (the flow.ir data-plane
1187    /// producer name), matching pre-GH-#23 behavior byte-for-byte.
1188    #[serde(default, skip_serializing_if = "Option::is_none")]
1189    pub projection_name: Option<String>,
1190}
1191
1192// ──────────────────────────────────────────────────────────────────────────
1193// Compiler hints / strategy
1194// ──────────────────────────────────────────────────────────────────────────
1195
1196/// Per-agent overrides / hints. Interpreted by the Compiler / SpawnerFactory; not required.
1197#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default, JsonSchema)]
1198#[serde(deny_unknown_fields)]
1199pub struct CompilerHints {
1200    /// Agent name → per-agent hint (= passed to `SpawnerFactory.build`).
1201    #[serde(default)]
1202    pub per_agent: HashMap<String, Value>,
1203    /// Global hints (= e.g. parallel limit, default timeout, ...).
1204    #[serde(default)]
1205    pub global: Value,
1206}
1207
1208/// Compiler behavior rules. Controls strict / lenient handling and default fallback.
1209#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, JsonSchema)]
1210#[serde(deny_unknown_fields)]
1211pub struct CompilerStrategy {
1212    /// If `true` (default), an unresolved `Step.ref` is an error; if `false`, it falls
1213    /// through to the default Spawner.
1214    #[serde(default = "default_true")]
1215    pub strict_refs: bool,
1216    /// If `true` (default), an `AgentKind` missing from the registry is an error; if
1217    /// `false`, it is skipped.
1218    #[serde(default = "default_true")]
1219    pub strict_kind: bool,
1220}
1221
1222fn default_true() -> bool {
1223    true
1224}
1225
1226impl Default for CompilerStrategy {
1227    fn default() -> Self {
1228        Self {
1229            strict_refs: true,
1230            strict_kind: true,
1231        }
1232    }
1233}
1234
1235// ──────────────────────────────────────────────────────────────────────────
1236// Blueprint metadata / origin
1237// ──────────────────────────────────────────────────────────────────────────
1238
1239/// Blueprint-level metadata (description / origin / tags / ttl / version label / alias).
1240#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default, JsonSchema)]
1241#[serde(deny_unknown_fields)]
1242pub struct BlueprintMetadata {
1243    /// Short human-readable description of the Blueprint.
1244    #[serde(default)]
1245    pub description: Option<String>,
1246    /// Provenance record (inline / file / algocline).
1247    #[serde(default)]
1248    pub origin: BlueprintOrigin,
1249    /// Tag list for classification / routing.
1250    #[serde(default)]
1251    pub tags: Vec<String>,
1252    /// Optional SemVer label (= match target for `TaskPipeline VersionSelector::SemVerReq`).
1253    /// Example: `"1.2.3"`. Rewritten by `EnhanceAdapter` on PATCH/MINOR/MAJOR bumps.
1254    #[serde(default, skip_serializing_if = "Option::is_none")]
1255    pub version_label: Option<String>,
1256    /// Optional LDS session alias label. The Swarm engine itself does not apply this
1257    /// (= it is free-form content); the value is expanded into the Spawn directive and
1258    /// reaches the MainAI. The MainAI is expected to establish a task session via
1259    /// `mcp__lds__session_create(root=..., alias=<this>)`, and to inject
1260    /// `LDS Session Alias: <this>` verbatim into the SubAgent dispatch prompt body.
1261    /// The SubAgent body then calls `mcp__lds__session_start(alias=<this>)` with the
1262    /// received alias. Worktree ownership is thereby unified under a single session, and
1263    /// cross-SubAgent / cross-worktree ownership blocks (= `not owned by this session`)
1264    /// cannot fire structurally.
1265    #[serde(default, skip_serializing_if = "Option::is_none")]
1266    pub project_name_alias: Option<String>,
1267    /// Optional default TTL (seconds) for tasks dispatched via this BP. Estimated by the
1268    /// Blueprint author from the flow shape (agent count × expected duration per agent).
1269    /// If `POST /v1/tasks` supplies `ttl_secs` explicitly, the body value wins; otherwise
1270    /// this metadata field is used as the default; if both are absent, the server global
1271    /// default (`default_run_ttl()` = 1800s) applies. Not needed for short chains (~5 min);
1272    /// recommended for long chains (14 agents × several minutes = 30-60 min).
1273    #[serde(default, skip_serializing_if = "Option::is_none")]
1274    pub default_run_ttl_secs: Option<u64>,
1275    /// GH #50 follow-up (issue `33bc825b`): promote `VerdictValueUnhandled`
1276    /// compile-time lint to a hard error. When `false` (or absent), a
1277    /// declared `AgentDef.verdict.values` entry that no downstream cond
1278    /// references is only surfaced via `tracing::warn!` (informational);
1279    /// when `true`, `Compiler::compile` rejects the Blueprint with
1280    /// `CompileError::VerdictValueUnhandled`. Opt-in so existing Blueprints
1281    /// that intentionally leave some verdict values as silent-pass
1282    /// informational tokens keep compiling unchanged.
1283    #[serde(default, skip_serializing_if = "Option::is_none")]
1284    pub strict_verdict_handling: Option<bool>,
1285}
1286
1287/// Provenance record of a Blueprint.
1288#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default, JsonSchema)]
1289#[serde(tag = "kind", rename_all = "snake_case")]
1290pub enum BlueprintOrigin {
1291    /// Inline construction, e.g. via a Rust struct literal or test code.
1292    #[default]
1293    Inline,
1294    /// Loaded from a file.
1295    File {
1296        /// Source file path.
1297        path: String,
1298    },
1299    /// Emitted by an algocline strategy (traced by `session_id`).
1300    Algo {
1301        /// Algocline session identifier.
1302        session_id: String,
1303    },
1304}
1305
1306#[cfg(test)]
1307mod tests {
1308    use super::*;
1309
1310    #[test]
1311    fn schema_version_default_parses() {
1312        let v = default_schema_version();
1313        assert_eq!(v.to_string(), "0.1.0");
1314    }
1315
1316    #[test]
1317    fn current_schema_version_const_matches() {
1318        assert_eq!(CURRENT_SCHEMA_VERSION, "0.1.0");
1319    }
1320
1321    #[test]
1322    fn blueprint_json_schema_exports_key_properties() {
1323        let schema = schemars::schema_for!(Blueprint);
1324        let v = serde_json::to_value(&schema).expect("schema serializes");
1325        let props = v["properties"].as_object().expect("object schema");
1326        for key in [
1327            "schema_version",
1328            "id",
1329            "flow",
1330            "agents",
1331            "operators",
1332            "metas",
1333            "hints",
1334            "strategy",
1335            "metadata",
1336            "spawner_hints",
1337            "default_agent_kind",
1338            "default_operator_kind",
1339            "default_init_ctx",
1340            "default_agent_ctx",
1341            "default_context_policy",
1342            "projection_placement",
1343            "audits",
1344            "runners",
1345            "default_runner",
1346            "check_policy",
1347        ] {
1348            assert!(props.contains_key(key), "missing property: {key}");
1349        }
1350        // semver override lands as a plain string
1351        assert_eq!(v["properties"]["schema_version"]["type"], "string");
1352        // enum variants (snake_case) survive into the schema (LLM author axis)
1353        let dump = v.to_string();
1354        assert!(dump.contains("agent_block"), "AgentKind variants in schema");
1355        assert!(dump.contains("main_ai"), "OperatorKind variants in schema");
1356        // nested defs are referenced (AgentDef reachable from agents[])
1357        assert!(dump.contains("AgentDef"), "AgentDef definition in schema");
1358    }
1359
1360    #[test]
1361    fn agent_profile_worker_binding_roundtrips_when_some() {
1362        let profile = AgentProfile {
1363            worker_binding: Some("mse-worker-coder".to_string()),
1364            ..Default::default()
1365        };
1366        let json = serde_json::to_value(&profile).expect("serializes");
1367        assert_eq!(json["worker_binding"], "mse-worker-coder");
1368        let back: AgentProfile = serde_json::from_value(json).expect("deserializes");
1369        assert_eq!(back.worker_binding.as_deref(), Some("mse-worker-coder"));
1370    }
1371
1372    #[test]
1373    fn agent_profile_worker_binding_omitted_when_none() {
1374        let profile = AgentProfile::default();
1375        let json = serde_json::to_value(&profile).expect("serializes");
1376        // `skip_serializing_if = "Option::is_none"` — the key must not appear at all.
1377        assert!(
1378            json.as_object().unwrap().get("worker_binding").is_none(),
1379            "worker_binding key must be absent when None: {json}"
1380        );
1381        let back: AgentProfile = serde_json::from_value(json).expect("deserializes");
1382        assert_eq!(back.worker_binding, None);
1383    }
1384
1385    // ──────────────────────────────────────────────────────────────
1386    // issue #19 ST3: `Blueprint.default_init_ctx`
1387    // ──────────────────────────────────────────────────────────────
1388
1389    fn minimal_bp(default_init_ctx: Option<Value>) -> Blueprint {
1390        Blueprint {
1391            schema_version: current_schema_version(),
1392            id: "bp-init-ctx-ut".into(),
1393            flow: FlowNode::Seq { children: vec![] },
1394            agents: vec![],
1395            operators: vec![],
1396            metas: vec![],
1397            hints: Default::default(),
1398            strategy: Default::default(),
1399            metadata: Default::default(),
1400            spawner_hints: Default::default(),
1401            default_agent_kind: AgentKind::Operator,
1402            default_operator_kind: None,
1403            default_init_ctx,
1404            default_agent_ctx: None,
1405            default_context_policy: None,
1406            projection_placement: None,
1407            audits: vec![],
1408            degradation_policy: None,
1409            runners: vec![],
1410            default_runner: None,
1411            check_policy: None,
1412        }
1413    }
1414
1415    #[test]
1416    fn blueprint_default_init_ctx_roundtrips_when_some() {
1417        let bp = minimal_bp(Some(serde_json::json!({ "seeded": true })));
1418        let json = serde_json::to_string(&bp).expect("serializes");
1419        let back: Blueprint = serde_json::from_str(&json).expect("deserializes");
1420        assert_eq!(
1421            back.default_init_ctx,
1422            Some(serde_json::json!({ "seeded": true }))
1423        );
1424        assert_eq!(bp, back);
1425    }
1426
1427    #[test]
1428    fn blueprint_default_init_ctx_omitted_when_none() {
1429        let bp = minimal_bp(None);
1430        let json = serde_json::to_value(&bp).expect("serializes");
1431        // `skip_serializing_if = "Option::is_none"` — the key must not appear at all
1432        // (pre-#19 Blueprints round-trip byte-identical through this path).
1433        assert!(
1434            json.as_object().unwrap().get("default_init_ctx").is_none(),
1435            "default_init_ctx key must be absent when None: {json}"
1436        );
1437        let back: Blueprint = serde_json::from_value(json).expect("deserializes");
1438        assert_eq!(back.default_init_ctx, None);
1439        assert_eq!(bp, back);
1440    }
1441
1442    #[test]
1443    fn blueprint_json_schema_exports_default_init_ctx_as_nullable_value() {
1444        let schema = schemars::schema_for!(Blueprint);
1445        let v = serde_json::to_value(&schema).expect("schema serializes");
1446        assert!(
1447            v["properties"]["default_init_ctx"].is_object(),
1448            "default_init_ctx must appear in the exported schema: {v}"
1449        );
1450    }
1451
1452    // ──────────────────────────────────────────────────────────────
1453    // issue #21 Phase 1: `Blueprint.default_agent_ctx` /
1454    // `default_context_policy`, `AgentMeta.ctx` / `context_policy`,
1455    // `ContextPolicy`
1456    // ──────────────────────────────────────────────────────────────
1457
1458    #[test]
1459    fn blueprint_default_agent_ctx_and_context_policy_roundtrip_when_some() {
1460        let mut bp = minimal_bp(None);
1461        bp.default_agent_ctx = Some(serde_json::json!({ "org_conventions": "x" }));
1462        bp.default_context_policy = Some(ContextPolicy {
1463            include: Some(vec!["project_root".to_string()]),
1464            exclude: vec!["work_dir".to_string()],
1465            ..Default::default()
1466        });
1467        let json = serde_json::to_string(&bp).expect("serializes");
1468        let back: Blueprint = serde_json::from_str(&json).expect("deserializes");
1469        assert_eq!(bp, back);
1470        assert_eq!(
1471            back.default_agent_ctx,
1472            Some(serde_json::json!({ "org_conventions": "x" }))
1473        );
1474        assert_eq!(
1475            back.default_context_policy,
1476            Some(ContextPolicy {
1477                include: Some(vec!["project_root".to_string()]),
1478                exclude: vec!["work_dir".to_string()],
1479                ..Default::default()
1480            })
1481        );
1482    }
1483
1484    #[test]
1485    fn blueprint_default_agent_ctx_and_context_policy_omitted_when_none() {
1486        let bp = minimal_bp(None);
1487        let json = serde_json::to_value(&bp).expect("serializes");
1488        let obj = json.as_object().unwrap();
1489        assert!(
1490            obj.get("default_agent_ctx").is_none(),
1491            "default_agent_ctx key must be absent when None: {json}"
1492        );
1493        assert!(
1494            obj.get("default_context_policy").is_none(),
1495            "default_context_policy key must be absent when None: {json}"
1496        );
1497        let back: Blueprint = serde_json::from_value(json).expect("deserializes");
1498        assert_eq!(back.default_agent_ctx, None);
1499        assert_eq!(back.default_context_policy, None);
1500        assert_eq!(bp, back);
1501    }
1502
1503    #[test]
1504    fn blueprint_json_schema_exports_agent_ctx_and_context_policy() {
1505        let schema = schemars::schema_for!(Blueprint);
1506        let v = serde_json::to_value(&schema).expect("schema serializes");
1507        assert!(
1508            v["properties"]["default_agent_ctx"].is_object(),
1509            "default_agent_ctx must appear in the exported schema: {v}"
1510        );
1511        assert!(
1512            v["properties"]["default_context_policy"].is_object(),
1513            "default_context_policy must appear in the exported schema: {v}"
1514        );
1515    }
1516
1517    // ──────────────────────────────────────────────────────────────
1518    // GH #27 (follow-up to #23): `Blueprint.projection_placement` /
1519    // `ProjectionPlacementSpec`
1520    // ──────────────────────────────────────────────────────────────
1521
1522    #[test]
1523    fn blueprint_projection_placement_roundtrips_when_some() {
1524        let mut bp = minimal_bp(None);
1525        bp.projection_placement = Some(ProjectionPlacementSpec {
1526            root: Some("project_root".to_string()),
1527            dir_template: Some("custom/{task_id}/out".to_string()),
1528        });
1529        let json = serde_json::to_string(&bp).expect("serializes");
1530        let back: Blueprint = serde_json::from_str(&json).expect("deserializes");
1531        assert_eq!(bp, back);
1532        assert_eq!(
1533            back.projection_placement,
1534            Some(ProjectionPlacementSpec {
1535                root: Some("project_root".to_string()),
1536                dir_template: Some("custom/{task_id}/out".to_string()),
1537            })
1538        );
1539    }
1540
1541    #[test]
1542    fn blueprint_projection_placement_omitted_when_none() {
1543        let bp = minimal_bp(None);
1544        let json = serde_json::to_value(&bp).expect("serializes");
1545        assert!(
1546            json.as_object()
1547                .unwrap()
1548                .get("projection_placement")
1549                .is_none(),
1550            "projection_placement key must be absent when None: {json}"
1551        );
1552        let back: Blueprint = serde_json::from_value(json).expect("deserializes");
1553        assert_eq!(back.projection_placement, None);
1554        assert_eq!(bp, back);
1555    }
1556
1557    #[test]
1558    fn blueprint_json_schema_exports_projection_placement() {
1559        let schema = schemars::schema_for!(Blueprint);
1560        let v = serde_json::to_value(&schema).expect("schema serializes");
1561        assert!(
1562            v["properties"]["projection_placement"].is_object(),
1563            "projection_placement must appear in the exported schema: {v}"
1564        );
1565    }
1566
1567    #[test]
1568    fn agent_meta_ctx_and_context_policy_roundtrip_when_some() {
1569        let meta = AgentMeta {
1570            ctx: Some(serde_json::json!({ "k": "v" })),
1571            context_policy: Some(ContextPolicy {
1572                include: None,
1573                exclude: vec!["run_id".to_string()],
1574                ..Default::default()
1575            }),
1576            ..Default::default()
1577        };
1578        let json = serde_json::to_value(&meta).expect("serializes");
1579        let back: AgentMeta = serde_json::from_value(json).expect("deserializes");
1580        assert_eq!(back, meta);
1581    }
1582
1583    #[test]
1584    fn agent_meta_ctx_and_context_policy_omitted_when_none() {
1585        let meta = AgentMeta::default();
1586        let json = serde_json::to_value(&meta).expect("serializes");
1587        let obj = json.as_object().unwrap();
1588        assert!(
1589            obj.get("ctx").is_none(),
1590            "ctx key must be absent when None: {json}"
1591        );
1592        assert!(
1593            obj.get("context_policy").is_none(),
1594            "context_policy key must be absent when None: {json}"
1595        );
1596    }
1597
1598    #[test]
1599    fn agent_meta_json_schema_exports_ctx_context_policy_and_meta_ref() {
1600        let schema = schemars::schema_for!(AgentMeta);
1601        let v = serde_json::to_value(&schema).expect("schema serializes");
1602        let props = v["properties"].as_object().expect("object schema");
1603        for key in [
1604            "description",
1605            "version",
1606            "tags",
1607            "ctx",
1608            "context_policy",
1609            "meta_ref",
1610            "projection_name",
1611        ] {
1612            assert!(props.contains_key(key), "missing property: {key}");
1613        }
1614    }
1615
1616    // ──────────────────────────────────────────────────────────────
1617    // issue #21 Phase 2: `MetaDef`, `Blueprint.metas`, `AgentMeta.meta_ref`
1618    // ──────────────────────────────────────────────────────────────
1619
1620    #[test]
1621    fn meta_def_roundtrips_through_json() {
1622        let def = MetaDef {
1623            name: "heavy-scan".to_string(),
1624            ctx: serde_json::json!({ "work_dir": "/x" }),
1625        };
1626        let json = serde_json::to_value(&def).expect("serializes");
1627        let back: MetaDef = serde_json::from_value(json).expect("deserializes");
1628        assert_eq!(back, def);
1629    }
1630
1631    #[test]
1632    fn blueprint_metas_omitted_when_empty() {
1633        let bp = minimal_bp(None);
1634        let json = serde_json::to_value(&bp).expect("serializes");
1635        assert!(
1636            json.as_object().unwrap().get("metas").is_none(),
1637            "metas key must be absent when empty: {json}"
1638        );
1639        let back: Blueprint = serde_json::from_value(json).expect("deserializes");
1640        assert!(back.metas.is_empty());
1641        assert_eq!(bp, back);
1642    }
1643
1644    #[test]
1645    fn blueprint_metas_roundtrips_when_non_empty() {
1646        let mut bp = minimal_bp(None);
1647        bp.metas = vec![MetaDef {
1648            name: "heavy-scan".to_string(),
1649            ctx: serde_json::json!({ "work_dir": "/x" }),
1650        }];
1651        let json = serde_json::to_string(&bp).expect("serializes");
1652        let back: Blueprint = serde_json::from_str(&json).expect("deserializes");
1653        assert_eq!(bp, back);
1654        assert_eq!(back.metas.len(), 1);
1655        assert_eq!(back.metas[0].name, "heavy-scan");
1656    }
1657
1658    #[test]
1659    fn blueprint_json_schema_exports_metas() {
1660        let schema = schemars::schema_for!(Blueprint);
1661        let v = serde_json::to_value(&schema).expect("schema serializes");
1662        assert!(
1663            v["properties"]["metas"].is_object(),
1664            "metas must appear in the exported schema: {v}"
1665        );
1666        let dump = v.to_string();
1667        assert!(dump.contains("MetaDef"), "MetaDef definition in schema");
1668    }
1669
1670    #[test]
1671    fn agent_meta_meta_ref_roundtrips_when_some() {
1672        let meta = AgentMeta {
1673            meta_ref: Some("heavy-scan".to_string()),
1674            ..Default::default()
1675        };
1676        let json = serde_json::to_value(&meta).expect("serializes");
1677        assert_eq!(json["meta_ref"], "heavy-scan");
1678        let back: AgentMeta = serde_json::from_value(json).expect("deserializes");
1679        assert_eq!(back, meta);
1680    }
1681
1682    #[test]
1683    fn agent_meta_meta_ref_omitted_when_none() {
1684        let meta = AgentMeta::default();
1685        let json = serde_json::to_value(&meta).expect("serializes");
1686        assert!(
1687            json.as_object().unwrap().get("meta_ref").is_none(),
1688            "meta_ref key must be absent when None: {json}"
1689        );
1690        let back: AgentMeta = serde_json::from_value(json).expect("deserializes");
1691        assert_eq!(back.meta_ref, None);
1692    }
1693
1694    // ──────────────────────────────────────────────────────────────
1695    // GH #23: `AgentMeta.projection_name`
1696    // ──────────────────────────────────────────────────────────────
1697
1698    #[test]
1699    fn agent_meta_projection_name_roundtrips_when_some() {
1700        let meta = AgentMeta {
1701            projection_name: Some("plan".to_string()),
1702            ..Default::default()
1703        };
1704        let json = serde_json::to_value(&meta).expect("serializes");
1705        assert_eq!(json["projection_name"], "plan");
1706        let back: AgentMeta = serde_json::from_value(json).expect("deserializes");
1707        assert_eq!(back, meta);
1708    }
1709
1710    #[test]
1711    fn agent_meta_projection_name_omitted_when_none() {
1712        let meta = AgentMeta::default();
1713        let json = serde_json::to_value(&meta).expect("serializes");
1714        assert!(
1715            json.as_object().unwrap().get("projection_name").is_none(),
1716            "projection_name key must be absent when None: {json}"
1717        );
1718        let back: AgentMeta = serde_json::from_value(json).expect("deserializes");
1719        assert_eq!(back.projection_name, None);
1720        assert_eq!(back, meta);
1721    }
1722
1723    #[test]
1724    fn agent_meta_rejects_unknown_field_with_projection_name_present() {
1725        // `deny_unknown_fields` must still reject an unrelated stray key
1726        // even when `projection_name` is present alongside it (regression
1727        // guard: adding the field must not accidentally loosen the
1728        // contract for the rest of the struct).
1729        let json = serde_json::json!({
1730            "projection_name": "plan",
1731            "not_a_real_field": true
1732        });
1733        let err = serde_json::from_value::<AgentMeta>(json).unwrap_err();
1734        assert!(
1735            err.to_string().contains("not_a_real_field")
1736                || err.to_string().contains("unknown field"),
1737            "expected an unknown-field rejection, got: {err}"
1738        );
1739    }
1740
1741    #[test]
1742    fn context_policy_default_allows_everything() {
1743        let policy = ContextPolicy::default();
1744        assert!(policy.allows("project_root"));
1745        assert!(policy.allows("anything"));
1746    }
1747
1748    #[test]
1749    fn context_policy_include_only_allows_listed_names() {
1750        let policy = ContextPolicy {
1751            include: Some(vec!["project_root".to_string()]),
1752            exclude: vec![],
1753            ..Default::default()
1754        };
1755        assert!(policy.allows("project_root"));
1756        assert!(!policy.allows("work_dir"));
1757    }
1758
1759    #[test]
1760    fn context_policy_exclude_wins_over_include() {
1761        let policy = ContextPolicy {
1762            include: Some(vec!["project_root".to_string()]),
1763            exclude: vec!["project_root".to_string()],
1764            ..Default::default()
1765        };
1766        assert!(!policy.allows("project_root"));
1767    }
1768
1769    #[test]
1770    fn context_policy_roundtrips_through_json() {
1771        let policy = ContextPolicy {
1772            include: Some(vec!["a".to_string(), "b".to_string()]),
1773            exclude: vec!["c".to_string()],
1774            ..Default::default()
1775        };
1776        let json = serde_json::to_value(&policy).expect("serializes");
1777        let back: ContextPolicy = serde_json::from_value(json).expect("deserializes");
1778        assert_eq!(back, policy);
1779    }
1780
1781    #[test]
1782    fn context_policy_default_roundtrips_as_empty_object() {
1783        let policy = ContextPolicy::default();
1784        let json = serde_json::to_value(&policy).expect("serializes");
1785        assert_eq!(
1786            json,
1787            serde_json::json!({
1788                "include": null,
1789                "exclude": [],
1790                "steps": null,
1791                "steps_exclude": [],
1792            })
1793        );
1794        let back: ContextPolicy = serde_json::from_value(json).expect("deserializes");
1795        assert_eq!(back, policy);
1796    }
1797
1798    // ──────────────────────────────────────────────────────────────
1799    // ST5 (`projection-adapter`): `ContextPolicy.steps` / `steps_exclude`
1800    // ──────────────────────────────────────────────────────────────
1801
1802    #[test]
1803    fn context_policy_steps_default_allows_every_step() {
1804        let policy = ContextPolicy::default();
1805        assert!(policy.allows_step("planner"));
1806        assert!(policy.allows_step("anything"));
1807    }
1808
1809    #[test]
1810    fn context_policy_steps_include_only_allows_listed_names() {
1811        let policy = ContextPolicy {
1812            steps: Some(vec!["planner".to_string()]),
1813            ..Default::default()
1814        };
1815        assert!(policy.allows_step("planner"));
1816        assert!(!policy.allows_step("coder"));
1817    }
1818
1819    #[test]
1820    fn context_policy_steps_empty_list_allows_none() {
1821        let policy = ContextPolicy {
1822            steps: Some(vec![]),
1823            ..Default::default()
1824        };
1825        assert!(!policy.allows_step("planner"));
1826    }
1827
1828    #[test]
1829    fn context_policy_steps_exclude_wins_over_steps() {
1830        let policy = ContextPolicy {
1831            steps: Some(vec!["planner".to_string()]),
1832            steps_exclude: vec!["planner".to_string()],
1833            ..Default::default()
1834        };
1835        assert!(!policy.allows_step("planner"));
1836    }
1837
1838    #[test]
1839    fn context_policy_steps_roundtrips_through_json() {
1840        let policy = ContextPolicy {
1841            steps: Some(vec!["planner".to_string(), "coder".to_string()]),
1842            steps_exclude: vec!["reviewer".to_string()],
1843            ..Default::default()
1844        };
1845        let json = serde_json::to_value(&policy).expect("serializes");
1846        let back: ContextPolicy = serde_json::from_value(json).expect("deserializes");
1847        assert_eq!(back, policy);
1848    }
1849
1850    // ──────────────────────────────────────────────────────────────
1851    // GH #34: `AuditDef`, `AuditMode`, `Blueprint.audits`
1852    // ──────────────────────────────────────────────────────────────
1853
1854    #[test]
1855    fn blueprint_audits_omitted_when_empty() {
1856        let bp = minimal_bp(None);
1857        let json = serde_json::to_value(&bp).expect("serializes");
1858        assert!(
1859            json.as_object().unwrap().get("audits").is_none(),
1860            "audits key must be absent when empty: {json}"
1861        );
1862        let back: Blueprint = serde_json::from_value(json).expect("deserializes");
1863        assert!(back.audits.is_empty());
1864        assert_eq!(bp, back);
1865    }
1866
1867    #[test]
1868    fn blueprint_audits_roundtrips_when_non_empty() {
1869        let mut bp = minimal_bp(None);
1870        bp.audits = vec![AuditDef {
1871            agent: "auditor".to_string(),
1872            steps: Some(vec!["worker".to_string()]),
1873            mode: AuditMode::Sync,
1874        }];
1875        let json = serde_json::to_string(&bp).expect("serializes");
1876        let back: Blueprint = serde_json::from_str(&json).expect("deserializes");
1877        assert_eq!(bp, back);
1878        assert_eq!(back.audits.len(), 1);
1879        assert_eq!(back.audits[0].agent, "auditor");
1880        assert_eq!(back.audits[0].mode, AuditMode::Sync);
1881    }
1882
1883    #[test]
1884    fn audit_def_steps_none_and_mode_default_when_omitted() {
1885        let json = serde_json::json!({ "agent": "auditor" });
1886        let def: AuditDef = serde_json::from_value(json).expect("deserializes");
1887        assert_eq!(def.steps, None);
1888        assert_eq!(def.mode, AuditMode::Async);
1889    }
1890
1891    #[test]
1892    fn audit_def_rejects_unknown_field() {
1893        let json = serde_json::json!({ "agent": "auditor", "not_a_real_field": true });
1894        let err = serde_json::from_value::<AuditDef>(json).unwrap_err();
1895        assert!(
1896            err.to_string().contains("not_a_real_field")
1897                || err.to_string().contains("unknown field"),
1898            "expected an unknown-field rejection, got: {err}"
1899        );
1900    }
1901
1902    #[test]
1903    fn audit_mode_serializes_snake_case() {
1904        assert_eq!(
1905            serde_json::to_value(AuditMode::Async).unwrap(),
1906            serde_json::json!("async")
1907        );
1908        assert_eq!(
1909            serde_json::to_value(AuditMode::Sync).unwrap(),
1910            serde_json::json!("sync")
1911        );
1912    }
1913
1914    #[test]
1915    fn blueprint_json_schema_exports_audits_and_audit_def() {
1916        let schema = schemars::schema_for!(Blueprint);
1917        let v = serde_json::to_value(&schema).expect("schema serializes");
1918        assert!(
1919            v["properties"]["audits"].is_object(),
1920            "audits must appear in the exported schema: {v}"
1921        );
1922        let dump = v.to_string();
1923        assert!(dump.contains("AuditDef"), "AuditDef definition in schema");
1924    }
1925
1926    // ──────────────────────────────────────────────────────────────
1927    // GH #32: `Blueprint.degradation_policy`, `DegradationPolicy`
1928    // ──────────────────────────────────────────────────────────────
1929
1930    #[test]
1931    fn blueprint_without_degradation_policy_deserializes_to_none() {
1932        let json = serde_json::json!({
1933            "schema_version": current_schema_version(),
1934            "id": "no-degradation-policy-ut",
1935            "flow": { "kind": "seq", "children": [] },
1936        });
1937        let bp: Blueprint = serde_json::from_value(json).expect("deserializes");
1938        assert_eq!(bp.degradation_policy, None);
1939    }
1940
1941    #[test]
1942    fn blueprint_degradation_policy_omitted_when_none() {
1943        let bp = minimal_bp(None);
1944        let json = serde_json::to_value(&bp).expect("serializes");
1945        assert!(
1946            json.as_object()
1947                .unwrap()
1948                .get("degradation_policy")
1949                .is_none(),
1950            "degradation_policy key must be absent when None: {json}"
1951        );
1952    }
1953
1954    #[test]
1955    fn blueprint_degradation_policy_warn_and_fail_roundtrip() {
1956        for (label, expected) in [
1957            ("warn", DegradationPolicy::Warn),
1958            ("fail", DegradationPolicy::Fail),
1959        ] {
1960            let mut bp = minimal_bp(None);
1961            bp.degradation_policy = Some(expected);
1962            let json = serde_json::to_string(&bp).expect("serializes");
1963            assert!(json.contains(&format!("\"degradation_policy\":\"{label}\"")));
1964            let back: Blueprint = serde_json::from_str(&json).expect("deserializes");
1965            assert_eq!(back.degradation_policy, Some(expected));
1966        }
1967    }
1968
1969    #[test]
1970    fn degradation_policy_rejects_unknown_variant() {
1971        let json = serde_json::json!({
1972            "schema_version": current_schema_version(),
1973            "id": "degradation-policy-unknown-variant-ut",
1974            "flow": { "kind": "seq", "children": [] },
1975            "degradation_policy": "ignore",
1976        });
1977        let err = serde_json::from_value::<Blueprint>(json).unwrap_err();
1978        assert!(
1979            err.to_string().contains("unknown variant"),
1980            "expected an unknown-variant rejection, got: {err}"
1981        );
1982    }
1983
1984    // ──────────────────────────────────────────────────────────────
1985    // GH #46 Milestone 2: `Runner`, `RunnerDef`, `WorkerModel`,
1986    // `Blueprint.runners` / `default_runner`, `AgentDef.runner` /
1987    // `runner_ref`, `resolve_runner`
1988    // ──────────────────────────────────────────────────────────────
1989
1990    fn agent_with_runner(
1991        name: &str,
1992        profile: Option<AgentProfile>,
1993        runner: Option<Runner>,
1994        runner_ref: Option<String>,
1995    ) -> AgentDef {
1996        AgentDef {
1997            name: name.to_string(),
1998            kind: AgentKind::RustFn,
1999            spec: serde_json::json!({ "fn_id": name }),
2000            profile,
2001            meta: None,
2002            runner,
2003            runner_ref,
2004            verdict: None,
2005        }
2006    }
2007
2008    fn ws_runner(variant: &str, tools: Vec<&str>) -> Runner {
2009        Runner::WsClaudeCode {
2010            variant: variant.to_string(),
2011            tools: tools.into_iter().map(str::to_string).collect(),
2012        }
2013    }
2014
2015    fn agent_block_runner(tools: Vec<&str>) -> Runner {
2016        Runner::AgentBlockInProcess {
2017            tools: tools.into_iter().map(str::to_string).collect(),
2018        }
2019    }
2020
2021    // ─── round-trip byte-compat ─────────────────────────────────────
2022
2023    #[test]
2024    fn blueprint_without_runners_or_default_runner_deserializes_to_defaults() {
2025        let json = serde_json::json!({
2026            "schema_version": current_schema_version(),
2027            "id": "no-runners-ut",
2028            "flow": { "kind": "seq", "children": [] },
2029        });
2030        let bp: Blueprint = serde_json::from_value(json).expect("deserializes");
2031        assert!(bp.runners.is_empty());
2032        assert_eq!(bp.default_runner, None);
2033    }
2034
2035    #[test]
2036    fn blueprint_runners_omitted_when_empty() {
2037        let bp = minimal_bp(None);
2038        let json = serde_json::to_value(&bp).expect("serializes");
2039        assert!(
2040            json.as_object().unwrap().get("runners").is_none(),
2041            "runners key must be absent when empty: {json}"
2042        );
2043        let back: Blueprint = serde_json::from_value(json).expect("deserializes");
2044        assert!(back.runners.is_empty());
2045        assert_eq!(bp, back);
2046    }
2047
2048    #[test]
2049    fn blueprint_runners_roundtrips_when_non_empty() {
2050        let mut bp = minimal_bp(None);
2051        bp.runners = vec![RunnerDef {
2052            name: "claude-worker".to_string(),
2053            runner: ws_runner("mse-worker-coder", vec!["Read", "Grep"]),
2054        }];
2055        let json = serde_json::to_string(&bp).expect("serializes");
2056        let back: Blueprint = serde_json::from_str(&json).expect("deserializes");
2057        assert_eq!(bp, back);
2058        assert_eq!(back.runners.len(), 1);
2059        assert_eq!(back.runners[0].name, "claude-worker");
2060    }
2061
2062    #[test]
2063    fn blueprint_default_runner_roundtrips_when_some() {
2064        let mut bp = minimal_bp(None);
2065        bp.default_runner = Some("claude-worker".to_string());
2066        let json = serde_json::to_value(&bp).expect("serializes");
2067        assert_eq!(json["default_runner"], "claude-worker");
2068        let back: Blueprint = serde_json::from_value(json).expect("deserializes");
2069        assert_eq!(back, bp);
2070    }
2071
2072    #[test]
2073    fn blueprint_default_runner_omitted_when_none() {
2074        let bp = minimal_bp(None);
2075        let json = serde_json::to_value(&bp).expect("serializes");
2076        assert!(
2077            json.as_object().unwrap().get("default_runner").is_none(),
2078            "default_runner key must be absent when None: {json}"
2079        );
2080        let back: Blueprint = serde_json::from_value(json).expect("deserializes");
2081        assert_eq!(back, bp);
2082    }
2083
2084    #[test]
2085    fn blueprint_json_schema_exports_runners_and_default_runner() {
2086        let schema = schemars::schema_for!(Blueprint);
2087        let v = serde_json::to_value(&schema).expect("schema serializes");
2088        assert!(
2089            v["properties"]["runners"].is_object(),
2090            "runners must appear in the exported schema: {v}"
2091        );
2092        assert!(
2093            v["properties"]["default_runner"].is_object(),
2094            "default_runner must appear in the exported schema: {v}"
2095        );
2096        let dump = v.to_string();
2097        assert!(dump.contains("RunnerDef"), "RunnerDef definition in schema");
2098        assert!(dump.contains("Runner"), "Runner definition in schema");
2099    }
2100
2101    #[test]
2102    fn agent_def_runner_and_runner_ref_omitted_when_none() {
2103        let agent = agent_with_runner("scout", None, None, None);
2104        let json = serde_json::to_value(&agent).expect("serializes");
2105        let obj = json.as_object().unwrap();
2106        assert!(
2107            obj.get("runner").is_none(),
2108            "runner key must be absent when None: {json}"
2109        );
2110        assert!(
2111            obj.get("runner_ref").is_none(),
2112            "runner_ref key must be absent when None: {json}"
2113        );
2114        let back: AgentDef = serde_json::from_value(json).expect("deserializes");
2115        assert_eq!(back, agent);
2116    }
2117
2118    #[test]
2119    fn agent_def_runner_inline_roundtrips_when_some() {
2120        let agent = agent_with_runner("coder", None, Some(agent_block_runner(vec!["Bash"])), None);
2121        let json = serde_json::to_string(&agent).expect("serializes");
2122        let back: AgentDef = serde_json::from_str(&json).expect("deserializes");
2123        assert_eq!(back, agent);
2124    }
2125
2126    #[test]
2127    fn agent_def_runner_ref_roundtrips_when_some() {
2128        let agent = agent_with_runner("coder", None, None, Some("claude-worker".to_string()));
2129        let json = serde_json::to_value(&agent).expect("serializes");
2130        assert_eq!(json["runner_ref"], "claude-worker");
2131        let back: AgentDef = serde_json::from_value(json).expect("deserializes");
2132        assert_eq!(back, agent);
2133    }
2134
2135    #[test]
2136    fn agent_def_json_schema_exports_runner_and_runner_ref() {
2137        let schema = schemars::schema_for!(AgentDef);
2138        let v = serde_json::to_value(&schema).expect("schema serializes");
2139        let props = v["properties"].as_object().expect("object schema");
2140        for key in ["runner", "runner_ref"] {
2141            assert!(props.contains_key(key), "missing property: {key}");
2142        }
2143    }
2144
2145    #[test]
2146    fn runner_ws_claude_code_roundtrips_through_json_and_tags_backend() {
2147        let runner = ws_runner("mse-worker-coder", vec!["Read", "Grep"]);
2148        let json = serde_json::to_value(&runner).expect("serializes");
2149        assert_eq!(json["backend"], "ws_claude_code");
2150        assert_eq!(json["variant"], "mse-worker-coder");
2151        assert_eq!(json["tools"], serde_json::json!(["Read", "Grep"]));
2152        let back: Runner = serde_json::from_value(json).expect("deserializes");
2153        assert_eq!(back, runner);
2154    }
2155
2156    #[test]
2157    fn runner_agent_block_in_process_roundtrips_through_json_and_tags_backend() {
2158        let runner = agent_block_runner(vec!["Bash"]);
2159        let json = serde_json::to_value(&runner).expect("serializes");
2160        assert_eq!(json["backend"], "agent_block_in_process");
2161        assert_eq!(json["tools"], serde_json::json!(["Bash"]));
2162        let back: Runner = serde_json::from_value(json).expect("deserializes");
2163        assert_eq!(back, runner);
2164    }
2165
2166    #[test]
2167    fn runner_tools_omitted_when_empty() {
2168        let runner = ws_runner("mse-worker-coder", vec![]);
2169        let json = serde_json::to_value(&runner).expect("serializes");
2170        assert!(
2171            json.as_object().unwrap().get("tools").is_none(),
2172            "tools key must be absent when empty: {json}"
2173        );
2174        let back: Runner = serde_json::from_value(json).expect("deserializes");
2175        assert_eq!(back, runner);
2176    }
2177
2178    #[test]
2179    fn runner_rejects_unknown_field() {
2180        let json = serde_json::json!({
2181            "backend": "ws_claude_code",
2182            "variant": "x",
2183            "not_a_real_field": true,
2184        });
2185        let err = serde_json::from_value::<Runner>(json).unwrap_err();
2186        assert!(
2187            err.to_string().contains("not_a_real_field")
2188                || err.to_string().contains("unknown field"),
2189            "expected an unknown-field rejection, got: {err}"
2190        );
2191    }
2192
2193    #[test]
2194    fn runner_def_roundtrips_through_json() {
2195        let def = RunnerDef {
2196            name: "claude-worker".to_string(),
2197            runner: ws_runner("mse-worker-coder", vec!["Read"]),
2198        };
2199        let json = serde_json::to_value(&def).expect("serializes");
2200        let back: RunnerDef = serde_json::from_value(json).expect("deserializes");
2201        assert_eq!(back, def);
2202    }
2203
2204    #[test]
2205    fn worker_model_roundtrips_through_json() {
2206        let model = WorkerModel {
2207            runner: agent_block_runner(vec!["Bash"]),
2208            agent: agent_with_runner("coder", None, None, None),
2209        };
2210        let json = serde_json::to_value(&model).expect("serializes");
2211        let back: WorkerModel = serde_json::from_value(json).expect("deserializes");
2212        assert_eq!(back, model);
2213    }
2214
2215    // ─── resolve_runner cascade precedence ─────────────────────────
2216
2217    #[test]
2218    fn resolve_runner_inline_wins_over_everything() {
2219        let inline = agent_block_runner(vec!["Bash"]);
2220        let profile = AgentProfile {
2221            worker_binding: Some("legacy-variant".to_string()),
2222            tools: vec!["Read".to_string()],
2223            ..Default::default()
2224        };
2225        let agent = agent_with_runner(
2226            "coder",
2227            Some(profile),
2228            Some(inline.clone()),
2229            Some("registry-entry".to_string()),
2230        );
2231        let mut bp = minimal_bp(None);
2232        bp.default_runner = Some("registry-entry".to_string());
2233        bp.runners = vec![RunnerDef {
2234            name: "registry-entry".to_string(),
2235            runner: ws_runner("other-variant", vec![]),
2236        }];
2237        bp.agents = vec![agent.clone()];
2238
2239        let resolved = resolve_runner(&bp, &agent).expect("resolves");
2240        assert_eq!(resolved, Some(inline));
2241    }
2242
2243    #[test]
2244    fn resolve_runner_runner_ref_wins_over_legacy_fallback() {
2245        let profile = AgentProfile {
2246            worker_binding: Some("legacy-variant".to_string()),
2247            tools: vec!["Read".to_string()],
2248            ..Default::default()
2249        };
2250        let registry_runner = ws_runner("registry-variant", vec!["Grep"]);
2251        let agent = agent_with_runner(
2252            "coder",
2253            Some(profile),
2254            None,
2255            Some("registry-entry".to_string()),
2256        );
2257        let mut bp = minimal_bp(None);
2258        bp.runners = vec![RunnerDef {
2259            name: "registry-entry".to_string(),
2260            runner: registry_runner.clone(),
2261        }];
2262        bp.agents = vec![agent.clone()];
2263
2264        let resolved = resolve_runner(&bp, &agent).expect("resolves");
2265        assert_eq!(resolved, Some(registry_runner));
2266    }
2267
2268    #[test]
2269    fn resolve_runner_legacy_fallback_wins_over_default_runner() {
2270        let profile = AgentProfile {
2271            worker_binding: Some("legacy-variant".to_string()),
2272            tools: vec!["Read".to_string(), "Grep".to_string()],
2273            ..Default::default()
2274        };
2275        let agent = agent_with_runner("coder", Some(profile), None, None);
2276        let mut bp = minimal_bp(None);
2277        bp.default_runner = Some("registry-entry".to_string());
2278        bp.runners = vec![RunnerDef {
2279            name: "registry-entry".to_string(),
2280            runner: agent_block_runner(vec!["Bash"]),
2281        }];
2282        bp.agents = vec![agent.clone()];
2283
2284        let resolved = resolve_runner(&bp, &agent).expect("resolves");
2285        assert_eq!(
2286            resolved,
2287            Some(ws_runner("legacy-variant", vec!["Read", "Grep"]))
2288        );
2289    }
2290
2291    #[test]
2292    fn resolve_runner_default_runner_alone_when_no_agent_level_declaration() {
2293        let agent = agent_with_runner("coder", None, None, None);
2294        let mut bp = minimal_bp(None);
2295        bp.default_runner = Some("registry-entry".to_string());
2296        bp.runners = vec![RunnerDef {
2297            name: "registry-entry".to_string(),
2298            runner: agent_block_runner(vec!["Bash"]),
2299        }];
2300        bp.agents = vec![agent.clone()];
2301
2302        let resolved = resolve_runner(&bp, &agent).expect("resolves");
2303        assert_eq!(resolved, Some(agent_block_runner(vec!["Bash"])));
2304    }
2305
2306    #[test]
2307    fn resolve_runner_none_when_nothing_declared_through_any_tier() {
2308        let agent = agent_with_runner("coder", None, None, None);
2309        let bp = minimal_bp(None);
2310
2311        let resolved = resolve_runner(&bp, &agent).expect("resolves");
2312        assert_eq!(resolved, None);
2313    }
2314
2315    #[test]
2316    fn resolve_runner_unknown_runner_ref_errs() {
2317        let agent = agent_with_runner("coder", None, None, Some("no-such-entry".to_string()));
2318        let mut bp = minimal_bp(None);
2319        bp.runners = vec![RunnerDef {
2320            name: "registry-entry".to_string(),
2321            runner: agent_block_runner(vec![]),
2322        }];
2323        bp.agents = vec![agent.clone()];
2324
2325        let err = resolve_runner(&bp, &agent).expect_err("unresolved runner_ref");
2326        assert_eq!(
2327            err,
2328            RunnerResolveError::UnknownRunnerRef {
2329                agent: "coder".to_string(),
2330                ref_name: "no-such-entry".to_string(),
2331                available: vec!["registry-entry".to_string()],
2332            }
2333        );
2334    }
2335
2336    #[test]
2337    fn resolve_runner_unknown_default_runner_errs() {
2338        let agent = agent_with_runner("coder", None, None, None);
2339        let mut bp = minimal_bp(None);
2340        bp.default_runner = Some("no-such-entry".to_string());
2341        bp.runners = vec![RunnerDef {
2342            name: "registry-entry".to_string(),
2343            runner: agent_block_runner(vec![]),
2344        }];
2345        bp.agents = vec![agent.clone()];
2346
2347        let err = resolve_runner(&bp, &agent).expect_err("unresolved default_runner");
2348        assert_eq!(
2349            err,
2350            RunnerResolveError::UnknownDefaultRunner {
2351                ref_name: "no-such-entry".to_string(),
2352                available: vec!["registry-entry".to_string()],
2353            }
2354        );
2355    }
2356
2357    // ──────────────────────────────────────────────────────────────
2358    // GH #50: `AgentDef.verdict` / `VerdictContract` / `VerdictChannel`
2359    // ──────────────────────────────────────────────────────────────
2360
2361    #[test]
2362    fn verdict_contract_roundtrips_body_channel() {
2363        let json = serde_json::json!({"channel": "body", "values": ["PASS", "BLOCKED"]});
2364        let contract: VerdictContract = serde_json::from_value(json.clone()).expect("deserializes");
2365        assert_eq!(contract.channel, VerdictChannel::Body);
2366        assert_eq!(
2367            contract.values,
2368            vec!["PASS".to_string(), "BLOCKED".to_string()]
2369        );
2370        assert_eq!(serde_json::to_value(&contract).expect("serializes"), json);
2371    }
2372
2373    #[test]
2374    fn verdict_contract_roundtrips_part_channel() {
2375        let json = serde_json::json!({"channel": "part", "values": ["ALLOW"]});
2376        let contract: VerdictContract = serde_json::from_value(json.clone()).expect("deserializes");
2377        assert_eq!(contract.channel, VerdictChannel::Part);
2378        assert_eq!(serde_json::to_value(&contract).expect("serializes"), json);
2379    }
2380
2381    #[test]
2382    fn agent_def_verdict_omitted_when_none() {
2383        let agent = agent_with_runner("gate", None, None, None);
2384        let json = serde_json::to_value(&agent).expect("serializes");
2385        assert!(
2386            json.as_object().unwrap().get("verdict").is_none(),
2387            "verdict key must be absent when None: {json}"
2388        );
2389        let back: AgentDef = serde_json::from_value(json).expect("deserializes");
2390        assert_eq!(back.verdict, None);
2391    }
2392
2393    #[test]
2394    fn agent_def_verdict_roundtrips_when_some() {
2395        let mut agent = agent_with_runner("gate", None, None, None);
2396        agent.verdict = Some(VerdictContract {
2397            channel: VerdictChannel::Body,
2398            values: vec!["PASS".to_string(), "BLOCKED".to_string()],
2399        });
2400        let json = serde_json::to_value(&agent).expect("serializes");
2401        let back: AgentDef = serde_json::from_value(json).expect("deserializes");
2402        assert_eq!(back.verdict, agent.verdict);
2403    }
2404
2405    /// Acceptance criterion #2: the `02-verdict-loop.json` sample (no
2406    /// `verdict` field on any of its agents) must still deserialize
2407    /// unchanged under the new `#[serde(deny_unknown_fields)]`-constrained
2408    /// `AgentDef` — `verdict` is `#[serde(default)]`, so its absence is not
2409    /// an error.
2410    #[test]
2411    fn existing_verdict_loop_sample_deserializes_with_verdict_omitted() {
2412        const SAMPLE: &str =
2413            include_str!("../../mlua-swarm-cli/src/mcp/resources/samples/02-verdict-loop.json");
2414        let bp: Blueprint = serde_json::from_str(SAMPLE).expect("sample deserializes");
2415        assert_eq!(bp.agents.len(), 6);
2416        assert!(
2417            bp.agents.iter().all(|a| a.verdict.is_none()),
2418            "no agent in the sample declares a verdict contract"
2419        );
2420    }
2421
2422    // ──────────────────────────────────────────────────────────────
2423    // CheckPolicy enum relocation + Blueprint.check_policy
2424    // (T1: schema round-trip / omit→None / invalid→error)
2425    // ──────────────────────────────────────────────────────────────
2426
2427    /// The wire form is snake_case and byte-identical to the pre-relocation
2428    /// enum (`"silent"` / `"warn"` / `"strict"`), round-tripping in both
2429    /// directions — the relocation must not change the serde surface.
2430    #[test]
2431    fn check_policy_wire_form_round_trips() {
2432        for (variant, wire) in [
2433            (CheckPolicy::Silent, "silent"),
2434            (CheckPolicy::Warn, "warn"),
2435            (CheckPolicy::Strict, "strict"),
2436        ] {
2437            let json = serde_json::to_value(variant).expect("serializes");
2438            assert_eq!(json, serde_json::json!(wire), "wire form for {variant:?}");
2439            let back: CheckPolicy = serde_json::from_value(json).expect("deserializes");
2440            assert_eq!(back, variant, "round-trip for {variant:?}");
2441        }
2442    }
2443
2444    /// The default is `Warn` (preserves the pre-CheckPolicy fail-open
2445    /// behaviour of every submit-time projection sink).
2446    #[test]
2447    fn check_policy_default_is_warn() {
2448        assert_eq!(CheckPolicy::default(), CheckPolicy::Warn);
2449    }
2450
2451    /// A Blueprint that declares `check_policy: "strict"` parses to
2452    /// `Some(Strict)` and re-serializes with the same snake_case literal.
2453    #[test]
2454    fn blueprint_check_policy_strict_round_trips() {
2455        let json = serde_json::json!({
2456            "schema_version": current_schema_version(),
2457            "id": "check-policy-strict-ut",
2458            "flow": { "kind": "seq", "children": [] },
2459            "check_policy": "strict",
2460        });
2461        let bp: Blueprint = serde_json::from_value(json).expect("deserializes");
2462        assert_eq!(bp.check_policy, Some(CheckPolicy::Strict));
2463        let re = serde_json::to_string(&bp).expect("serializes");
2464        assert!(
2465            re.contains("\"check_policy\":\"strict\""),
2466            "re-serialized BP must preserve the snake_case wire literal: {re}"
2467        );
2468    }
2469
2470    /// An omitted `check_policy` parses to `None` and is skipped on
2471    /// serialize (backward-compat with every pre-cascade Blueprint).
2472    #[test]
2473    fn blueprint_check_policy_omitted_is_none() {
2474        let json = serde_json::json!({
2475            "schema_version": current_schema_version(),
2476            "id": "check-policy-omitted-ut",
2477            "flow": { "kind": "seq", "children": [] },
2478        });
2479        let bp: Blueprint = serde_json::from_value(json).expect("deserializes");
2480        assert_eq!(bp.check_policy, None);
2481
2482        let out = serde_json::to_value(&bp).expect("serializes");
2483        assert!(
2484            out.as_object().unwrap().get("check_policy").is_none(),
2485            "check_policy key must be absent when None: {out}"
2486        );
2487    }
2488
2489    /// An invalid `check_policy` value is a hard parse error (not silently
2490    /// dropped) — the enum is closed to the three snake_case variants. This
2491    /// also confirms `deny_unknown_fields` is not the gate here: the field
2492    /// IS known, only its value is invalid.
2493    #[test]
2494    fn blueprint_check_policy_invalid_value_errors() {
2495        let json = serde_json::json!({
2496            "schema_version": current_schema_version(),
2497            "id": "check-policy-invalid-ut",
2498            "flow": { "kind": "seq", "children": [] },
2499            "check_policy": "loud",
2500        });
2501        let err = serde_json::from_value::<Blueprint>(json)
2502            .expect_err("an unknown check_policy value must be rejected");
2503        let msg = err.to_string();
2504        assert!(
2505            msg.contains("check_policy") || msg.contains("loud") || msg.contains("variant"),
2506            "error should point at the bad check_policy value: {msg}"
2507        );
2508    }
2509}