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