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//!     subprocesses: vec![],
76//!     check_policy: None,
77//!     blueprint_ref_includes: vec![],
78//! };
79//!
80//! assert_eq!(bp.id.as_str(), "hello");
81//! assert_eq!(bp.agents.len(), 1);
82//! assert_eq!(bp.strategy.strict_refs, true);
83//! ```
84//!
85//! Round-trip a [`Blueprint`] through JSON (= confirms `serde` derives and the
86//! `deny_unknown_fields` contract):
87//!
88//! ```
89//! use mlua_swarm_schema::{AgentKind, Blueprint, BlueprintMetadata};
90//! use mlua_flow_ir::{Expr, Node};
91//! use serde_json::json;
92//!
93//! let bp = Blueprint {
94//!     schema_version: mlua_swarm_schema::current_schema_version(),
95//!     id: "roundtrip".into(),
96//!     flow: Node::Seq { children: vec![] },
97//!     agents: vec![],
98//!     operators: vec![],
99//!     metas: vec![],
100//!     hints: Default::default(),
101//!     strategy: Default::default(),
102//!     metadata: BlueprintMetadata {
103//!         description: Some("roundtrip smoke".into()),
104//!         default_run_ttl_secs: Some(1800),
105//!         ..Default::default()
106//!     },
107//!     spawner_hints: Default::default(),
108//!     default_agent_kind: AgentKind::Operator,
109//!     default_operator_kind: None,
110//!     default_init_ctx: None,
111//!     default_agent_ctx: None,
112//!     default_context_policy: None,
113//!     projection_placement: None,
114//!     audits: vec![],
115//!     degradation_policy: None,
116//!     runners: vec![],
117//!     default_runner: None,
118//!     subprocesses: vec![],
119//!     check_policy: None,
120//!     blueprint_ref_includes: vec![],
121//! };
122//!
123//! let json = serde_json::to_string(&bp).unwrap();
124//! let back: Blueprint = serde_json::from_str(&json).unwrap();
125//! assert_eq!(bp, back);
126//! assert_eq!(back.metadata.default_run_ttl_secs, Some(1800));
127//! ```
128
129#![warn(missing_docs)]
130
131use mlua_flow_ir::Node as FlowNode;
132use schemars::JsonSchema;
133use serde::{Deserialize, Serialize};
134use serde_json::Value;
135use std::collections::HashMap;
136
137// ──────────────────────────────────────────────────────────────────────────
138// Versioning
139// ──────────────────────────────────────────────────────────────────────────
140
141/// Current Blueprint schema version. Tied to this crate's semver.
142pub const CURRENT_SCHEMA_VERSION: &str = "0.1.0";
143
144fn default_schema_version() -> semver::Version {
145    current_schema_version()
146}
147
148/// Blueprint construction helper: returns the semver of the current schema version.
149/// Callers can write `schema_version: current_schema_version(),`.
150pub fn current_schema_version() -> semver::Version {
151    semver::Version::parse(CURRENT_SCHEMA_VERSION)
152        .expect("CURRENT_SCHEMA_VERSION must be valid semver")
153}
154
155// ──────────────────────────────────────────────────────────────────────────
156// BlueprintId (human-facing ID newtype)
157// ──────────────────────────────────────────────────────────────────────────
158
159/// Identifier for a Blueprint series — the domain name (`coding`,
160/// `design`, `testing`, etc.). Default: [`BlueprintId::main`].
161///
162/// One representation across the workspace (issue #14): this type is
163/// shared by the schema's [`Blueprint::id`] and the engine's store-layer
164/// keys (`mlua-swarm` re-exports it at the old
165/// `blueprint::store::types::BlueprintId` path). The value is
166/// user-supplied — there is no prefix convention to validate, unlike the
167/// engine's minted `T-` / `R-` / `ST-` ids — so construction is
168/// infallible; the inner string is private so call sites go through
169/// [`BlueprintId::new`] and the accessors. `#[serde(transparent)]` keeps
170/// both the JSON wire shape and the generated JSON Schema a plain string.
171#[derive(
172    Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize, JsonSchema,
173)]
174#[serde(transparent)]
175pub struct BlueprintId(String);
176
177impl BlueprintId {
178    /// The default series name used when a caller doesn't pick one.
179    pub const MAIN: &'static str = "main";
180
181    /// Shorthand for `BlueprintId::new(BlueprintId::MAIN)`.
182    pub fn main() -> Self {
183        Self(Self::MAIN.to_string())
184    }
185
186    /// Wrap any string-like value as a `BlueprintId` (user-supplied key;
187    /// nothing to validate).
188    pub fn new(s: impl Into<String>) -> Self {
189        Self(s.into())
190    }
191
192    /// Borrow the inner series name.
193    pub fn as_str(&self) -> &str {
194        &self.0
195    }
196
197    /// Consume the id and return the inner series name.
198    pub fn into_string(self) -> String {
199        self.0
200    }
201}
202
203impl std::fmt::Display for BlueprintId {
204    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
205        f.write_str(&self.0)
206    }
207}
208
209impl From<String> for BlueprintId {
210    fn from(s: String) -> Self {
211        Self(s)
212    }
213}
214
215impl From<&str> for BlueprintId {
216    fn from(s: &str) -> Self {
217        Self(s.to_string())
218    }
219}
220
221#[cfg(test)]
222mod blueprint_id_tests {
223    use super::*;
224
225    /// issue #14 convergence guard: `Blueprint.id` becoming a newtype must
226    /// not change the generated JSON Schema — the property stays an inline
227    /// plain string (no `$ref`), byte-compatible with the `String` era.
228    #[test]
229    fn blueprint_id_field_schema_stays_a_plain_inline_string() {
230        let schema = schemars::schema_for!(Blueprint);
231        let v = serde_json::to_value(&schema).expect("schema serializes");
232        let id = &v["properties"]["id"];
233        assert_eq!(id["type"], "string", "id must stay a plain string: {id}");
234        assert!(id.get("$ref").is_none(), "id must not become a $ref: {id}");
235    }
236
237    /// The JSON wire shape of the newtype is the bare string.
238    #[test]
239    fn blueprint_id_serde_is_transparent() {
240        let id = BlueprintId::new("coding");
241        assert_eq!(
242            serde_json::to_value(&id).unwrap(),
243            serde_json::json!("coding")
244        );
245        let back: BlueprintId = serde_json::from_value(serde_json::json!("coding")).unwrap();
246        assert_eq!(back, id);
247    }
248}
249
250// ──────────────────────────────────────────────────────────────────────────
251// Blueprint (top-level package)
252// ──────────────────────────────────────────────────────────────────────────
253
254/// Unified package of flow.ir + Swarm extension layers. The entry-point type of Swarm.
255#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, JsonSchema)]
256#[serde(deny_unknown_fields)]
257pub struct Blueprint {
258    /// Schema version (= tied to this crate's semver). Default = `CURRENT_SCHEMA_VERSION`.
259    /// Serialized as a semver string (e.g. `"0.1.0"`).
260    #[serde(default = "default_schema_version")]
261    #[schemars(with = "String")]
262    pub schema_version: semver::Version,
263    /// Blueprint identifier (= unique key within the caller's namespace).
264    #[schemars(with = "String")]
265    pub id: BlueprintId,
266    /// Embeds the flow.ir Node verbatim (= keeps flow.ir side unpolluted).
267    /// Opaque in the JSON Schema (the Node shape is owned by the `mlua-flow-ir`
268    /// crate, a separate repo; see its docs for the Node / Expr grammar).
269    #[schemars(with = "Value")]
270    pub flow: FlowNode,
271    /// Swarm extension layer: agent → backend mapping.
272    #[serde(default)]
273    pub agents: Vec<AgentDef>,
274    /// Swarm extension layer: **design-time definition** of Operator roles (first-class).
275    ///
276    /// `AgentDef.spec.operator_ref` references an `OperatorDef.name` (logical role name) in
277    /// this vec. Embedding runtime-generated IDs such as sid into the BP is forbidden
278    /// (= collapses the design-time vs runtime boundary). Runtime backend bindings are
279    /// established via the attach / register path; the BP side holds only logical names.
280    ///
281    /// Every `kind = Operator` agent must have its `spec.operator_ref` present in this
282    /// list — the compiler validates it at `compile()` time. May be `[]` only when the
283    /// Blueprint declares no Operator agents.
284    #[serde(default)]
285    pub operators: Vec<OperatorDef>,
286    /// GH #21 Phase 2 — named, BP-scoped pool of [`MetaDef`] entries. Two
287    /// independent consumers resolve names against this pool: a
288    /// `$step_meta.ref` envelope embedded in a Step's evaluated `in`
289    /// value (the Step tier — resolved by `EngineDispatcher` in the
290    /// `mlua-swarm` core crate at dispatch time), and
291    /// [`AgentMeta::meta_ref`] (the Agent tier — resolved at launch
292    /// time). The pool lets multiple Steps and/or Agents share one
293    /// declarative context object by name instead of repeating it
294    /// inline. `[]` = no named `MetaDef`s declared (pre-#21-Phase-2
295    /// Blueprints unaffected).
296    #[serde(default, skip_serializing_if = "Vec::is_empty")]
297    pub metas: Vec<MetaDef>,
298    /// Swarm extension layer: per-agent hints (interpreted by the Compiler).
299    #[serde(default)]
300    pub hints: CompilerHints,
301    /// Swarm extension layer: Compiler behavior strategy (strict / lenient).
302    #[serde(default)]
303    pub strategy: CompilerStrategy,
304    /// Blueprint metadata (description / origin / tags / ttl / version label / alias).
305    #[serde(default)]
306    pub metadata: BlueprintMetadata,
307    /// Swarm extension layer: hint keys of the layers to wrap around the SpawnerStack.
308    /// Resolved by the LayerRegistry at engine bind time (= unregistered keys are silently
309    /// skipped). Flow / Blueprint do not hold middleware implementations (e.g. MainAIMiddleware)
310    /// directly; they only declare required capabilities as string keys (= implementations
311    /// live in the engine-side LayerRegistry).
312    #[serde(default)]
313    pub spawner_hints: SpawnerHints,
314    /// BP-wide default `AgentKind` (= fallback when `AgentDef.kind` is omitted).
315    /// Four-layer cascade: (1) Schema impl Default = Operator, (2) CLI
316    /// `--default-agent-kind`, (3) this field (BP JSON literal), (4) `AgentDef.kind`
317    /// (per-agent literal). (5) `CompilerHints.kind_override` allows runtime override.
318    /// All default resolution flows through this path.
319    #[serde(default = "default_global_agent_kind")]
320    pub default_agent_kind: AgentKind,
321    /// BP-wide default `OperatorKind` (= the "BP Global" tier of the 4-tier
322    /// `OperatorKind` cascade). `None` when the Blueprint author does not
323    /// declare a default; the caller-side resolver then falls through to
324    /// the hardcoded `OperatorKind::default()` (Automate).
325    ///
326    /// # 4-tier cascade (highest to lowest priority)
327    ///
328    /// 1. Runtime Agent-level (per-agent override supplied at task-launch time)
329    /// 2. Runtime Global (the launch-time `operator_kind` request)
330    /// 3. BP Agent-level (`OperatorDef.kind`, resolved via `AgentDef.spec.operator_ref`)
331    /// 4. BP Global (this field)
332    /// 5. Default Fallback (`OperatorKind::default()` = Automate)
333    ///
334    /// The collapse itself is implemented once on the engine side and consumed
335    /// per-agent when resolving operator info.
336    #[serde(default, skip_serializing_if = "Option::is_none")]
337    pub default_operator_kind: Option<OperatorKind>,
338    /// Blueprint-level default initial `ctx` for flow-ir eval.
339    /// `TaskLaunchService::launch` shallow-merges this with the
340    /// Task-level `init_ctx` (Task wins on key collision when both
341    /// are `Object`; if Task's `init_ctx` is not an `Object`, it
342    /// full-replaces the default). `None` — no default is merged;
343    /// backward-compat with pre-#19 Blueprints.
344    #[serde(default, skip_serializing_if = "Option::is_none")]
345    #[schemars(with = "Option<Value>")]
346    pub default_init_ctx: Option<Value>,
347    /// GH #21 Phase 1 — "BP Global" tier of the agent-context supply axis:
348    /// a declarative object merged into `ctx.meta.runtime` (and, for
349    /// unnamed keys, `AgentContextView.extra`) targeting every agent's
350    /// runtime materialization. Contrast with [`Self::default_init_ctx`]:
351    /// that field seeds the flow-ir eval `ctx` once at flow start, while
352    /// this one is consumed per-spawn by
353    /// `AgentContextMiddleware`/`AgentContextView` (Contract C, GH #20) —
354    /// a pure flow-ir eval seed vs. an Agent/LLM-boundary runtime default.
355    /// `None` = no BP-global default (pre-#21 Blueprints unaffected).
356    #[serde(default, skip_serializing_if = "Option::is_none")]
357    #[schemars(with = "Option<Value>")]
358    pub default_agent_ctx: Option<Value>,
359    /// GH #21 Phase 1 — "BP Global" tier of the [`ContextPolicy`] cascade:
360    /// the default filter applied to the materialized `AgentContextView`
361    /// when the targeted agent declares no `AgentMeta.context_policy` of
362    /// its own. `None` = pass-all (the pre-#21 behavior).
363    #[serde(default, skip_serializing_if = "Option::is_none")]
364    pub default_context_policy: Option<ContextPolicy>,
365    /// GH #27 (follow-up to #23) — Blueprint-declared override of the
366    /// `mlua-swarm` core crate's projection placement resolver (root
367    /// preference + target directory template for materialized step
368    /// OUTPUT files). `None` = the resolver's byte-compat default (root =
369    /// `work_dir` falling back to `project_root`; dir_template =
370    /// `"workspace/tasks/{task_id}/ctx"`) — every pre-#27 Blueprint is
371    /// unaffected. See [`ProjectionPlacementSpec`]'s doc for field detail.
372    #[serde(default, skip_serializing_if = "Option::is_none")]
373    pub projection_placement: Option<ProjectionPlacementSpec>,
374    /// GH #34 — Blueprint-declared after-run audit hooks: the engine
375    /// auto-kicks each listed [`AuditDef`]'s agent once a matching Step
376    /// settles, and persists its findings as an `OutputEvent::Artifact`
377    /// named `"audit:<step_ref>"` on the AUDITED step's own output tail
378    /// (see `mlua-swarm` core's `AfterRunAuditMiddleware` for the
379    /// dispatch mechanics). `audits[].agent` is validated at
380    /// `Compiler::compile` time against `Blueprint.agents[].name`
381    /// (mirrors the `operator_ref` validation). `[]` (the default) = no
382    /// audit hooks declared — every pre-#34 Blueprint is unaffected,
383    /// byte-for-byte.
384    ///
385    /// **Binding invariant**: an audit's verdict, findings, or even its
386    /// own failure NEVER change the audited step's outcome or gate the
387    /// flow — audits are purely observational.
388    #[serde(default, skip_serializing_if = "Vec::is_empty")]
389    pub audits: Vec<AuditDef>,
390    /// GH #32 — Blueprint-declared policy for worker-reported degradations
391    /// (see `mlua-swarm` core's `RunRecord.degradations` /
392    /// `DegradationEntry`). `None` (the default) is schema-only for now:
393    /// [`DegradationPolicy::Warn`] and [`DegradationPolicy::Fail`] carry the
394    /// same observational behavior at this point — degradations are always
395    /// persisted, never gate the flow. Engine enforcement of `Fail`
396    /// (terminating a Run on any reported degradation) is a follow-up; this
397    /// field only declares author intent today. Every pre-#32 Blueprint is
398    /// unaffected.
399    #[serde(default, skip_serializing_if = "Option::is_none")]
400    pub degradation_policy: Option<DegradationPolicy>,
401    /// GH #46 M2 — named registry of [`RunnerDef`] entries (Tier 1 of the
402    /// 3-tier Worker model: Runner / Agent / Context). Referenced by
403    /// `AgentDef.runner_ref` and [`Self::default_runner`] by name.
404    /// Same registry shape as [`Self::metas`] (GH #21 Phase 2). `[]` (the
405    /// default) = no Runner registry declared — every pre-#46 Blueprint
406    /// is unaffected, byte-for-byte.
407    #[serde(default, skip_serializing_if = "Vec::is_empty")]
408    pub runners: Vec<RunnerDef>,
409    /// GH #46 M2 — the "BP Global" tier of the [`resolve_runner`] cascade:
410    /// a [`RunnerDef::name`] reference into [`Self::runners`] (inline
411    /// `Runner` values are not accepted here — registry names only,
412    /// mirroring [`Self::default_agent_ctx`]'s design). Ranks BELOW an
413    /// agent's own inline `runner` / `runner_ref` / legacy
414    /// `profile.worker_binding` declaration (see [`resolve_runner`]'s
415    /// cascade doc for the full precedence). `None` = no BP-wide default
416    /// declared — every pre-#46 Blueprint is unaffected.
417    #[serde(default, skip_serializing_if = "Option::is_none")]
418    pub default_runner: Option<String>,
419    /// GH #83 — named registry of [`SubprocessDef`] CLI invocation
420    /// templates, referenced by `Runner::Subprocess { template }` by
421    /// name. Same registry shape as [`Self::metas`] / [`Self::runners`].
422    /// `[]` (the default) = no templates declared — every pre-#83
423    /// Blueprint is unaffected, byte-for-byte.
424    #[serde(default, skip_serializing_if = "Vec::is_empty")]
425    pub subprocesses: Vec<SubprocessDef>,
426    /// "Blueprint" tier (tier 2) of the `check_policy`
427    /// cascade: `launch request > blueprint > server config` (highest to
428    /// lowest priority). The launch entry point resolves
429    /// `launch.check_policy.or(blueprint.check_policy)` exactly once and
430    /// threads the result into every spawned step's `TaskSpec.check_policy`;
431    /// `None` here (the default) is a no declaration — resolution falls
432    /// through to the launch-request tier and, absent that, to the
433    /// server-wide `EngineCfg.check_policy` default. Every pre-cascade
434    /// Blueprint is unaffected, byte-for-byte. See [`CheckPolicy`] for the
435    /// three fail-open reaction modes.
436    #[serde(default, skip_serializing_if = "Option::is_none")]
437    pub check_policy: Option<CheckPolicy>,
438    /// Authoring-time include list consumed by the compile-side linker
439    /// (tier 2 of the include cascade — see `mlua-swarm-compile`'s
440    /// `ResolveConfig`). Each entry is a directory path resolved
441    /// relative to the bp.lua parent that `$agent_md` / `$file` refs
442    /// will search after the parent dir itself. Bare list; the schema
443    /// carries the field only so `deny_unknown_fields` won't reject a
444    /// bp.lua that declares it. `[]` (the default) — no in-bp includes;
445    /// every pre-cascade Blueprint is unaffected.
446    #[serde(default, skip_serializing_if = "Vec::is_empty")]
447    #[schemars(with = "Vec<String>")]
448    pub blueprint_ref_includes: Vec<std::path::PathBuf>,
449}
450
451/// How a submit-time projection sink reacts when a fail-open condition
452/// is encountered.
453///
454/// This is the Swarm IF SoT type for the `check_policy` axis; the
455/// `mlua-swarm` core crate re-exports it as `crate::core::config::CheckPolicy`
456/// so every existing path (`EngineCfg.check_policy`, `TaskSpec.check_policy`,
457/// `apply_check_policy`) keeps its old type path unchanged.
458///
459/// Fail-open conditions include: `work_dir` / `project_root` unresolved,
460/// `OutputStore` write error, `FileProjectionAdapter::materialize_submission`
461/// error, and state lookup error. Each call site inside the engine's
462/// `materialize_final_submission` / `materialize_artifact_submission`
463/// currently logs a `tracing::warn!` and returns without materializing the
464/// file / dual-write; `CheckPolicy` is the first-class knob that lets a
465/// caller opt into a different reaction without changing that behaviour by
466/// default.
467///
468/// The three modes are (a) [`CheckPolicy::Silent`] — no log, no error,
469/// operation continues; (b) [`CheckPolicy::Warn`] — log warn (existing
470/// message literal preserved), no error, operation continues (the
471/// default = pre-existing behaviour); (c) [`CheckPolicy::Strict`] — log
472/// the same warn AND return `EngineError::CheckPolicyStrict` (in the core
473/// crate) so the caller can fail the step / launch fast. When Strict
474/// returns an error, the underlying `OutputStore` may already have
475/// appended (dual-write side-effect is not rolled back) — this "state
476/// dirty on fail" semantics is intentional: the append happens **before**
477/// the fail-open branch runs, so Strict surfaces the mismatch instead of
478/// hiding it.
479///
480/// The wire form is snake_case (`"silent"` / `"warn"` / `"strict"`); the
481/// default is [`CheckPolicy::Warn`].
482#[derive(Copy, Clone, Debug, PartialEq, Eq, Default, Serialize, Deserialize, JsonSchema)]
483#[serde(rename_all = "snake_case")]
484pub enum CheckPolicy {
485    /// Skip both the log warn and the error path — completely silent.
486    /// The operation continues (fail-open is still in effect).
487    Silent,
488    /// Log a `tracing::warn!` with the call site's existing message and
489    /// continue (fail-open). Default — byte-identical to the
490    /// pre-`CheckPolicy` behaviour of every submit-time projection sink
491    /// code path.
492    #[default]
493    Warn,
494    /// Log the same warn AND return `EngineError::CheckPolicyStrict` (the
495    /// core crate's error variant). A caller that has opted in can fail the
496    /// step / launch fast instead of proceeding with a partially-realized
497    /// submission. This mode also drives a launch-time pre-dispatch
498    /// validation in `TaskLaunchService::launch` (the `mlua-swarm` core
499    /// crate): a launch whose effective policy resolves to `Strict` and
500    /// that supplies neither `project_root` nor `work_dir` is rejected
501    /// with `TaskLaunchError::PreDispatch` before any step is dispatched,
502    /// rather than dispatching a step that would deterministically hit
503    /// this same error at its first submit-time file materialize.
504    Strict,
505}
506
507/// GH #32 — Blueprint-declared policy for worker-reported degradations. See
508/// [`Blueprint::degradation_policy`] for the (currently schema-only)
509/// enforcement contract.
510#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
511#[serde(rename_all = "snake_case")]
512pub enum DegradationPolicy {
513    /// Observational only (today's only enforced behavior, regardless of
514    /// which variant is declared): degradations are persisted to
515    /// `RunRecord.degradations` and surfaced via `mse_doctor` /
516    /// `GET /v1/runs/:id`, but never change the Run's outcome.
517    Warn,
518    /// Declares intent to terminate the Run on any reported degradation.
519    /// Not yet enforced by the engine — schema-only until the follow-up
520    /// lands.
521    Fail,
522}
523
524/// GH #34 — one Blueprint-declared after-run audit hook. See
525/// [`Blueprint::audits`] for the persistence / invariant contract.
526#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, JsonSchema)]
527#[serde(deny_unknown_fields)]
528pub struct AuditDef {
529    /// Name of the audit agent (must match a [`Blueprint::agents`] entry's
530    /// `name`) the engine dispatches after a matched step settles.
531    /// Validated at `Compiler::compile` time (mirrors
532    /// `AgentDef.spec.operator_ref`'s `operator_ref` validation) — an
533    /// unresolved name rejects compilation.
534    pub agent: String,
535    /// Step names this audit applies to, matched against the step's agent
536    /// ref name. `None`, or a list containing the literal `"*"`, means
537    /// "every step". `Some(vec![])` (an explicit empty list) audits no
538    /// step. `None` is the default.
539    #[serde(default, skip_serializing_if = "Option::is_none")]
540    pub steps: Option<Vec<String>>,
541    /// Dispatch timing for this audit's agent (see [`AuditMode`]).
542    /// Defaults to [`AuditMode::Async`].
543    #[serde(default)]
544    pub mode: AuditMode,
545}
546
547/// GH #34 — dispatch timing for an [`AuditDef`]'s audit agent. Neither
548/// variant ever changes the audited step's outcome (see
549/// [`Blueprint::audits`]'s binding invariant).
550#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize, JsonSchema)]
551#[serde(rename_all = "snake_case")]
552pub enum AuditMode {
553    /// Fire-and-forget: the audit runs in the background after the
554    /// audited step settles; the audited step's own spawn signal returns
555    /// immediately, without waiting for the audit to finish.
556    #[default]
557    Async,
558    /// Awaited before the audited step's spawn signal is returned to the
559    /// engine — still never alters that signal or the step's recorded
560    /// outcome.
561    Sync,
562}
563
564/// Receptacle for a Blueprint-driven filter over the materialized
565/// `AgentContextView` (GH #20/#21). Declared BP-side via
566/// [`Blueprint::default_context_policy`] (BP-global) or
567/// `AgentMeta::context_policy` (per-agent, outranks the BP-global tier) —
568/// resolved and applied by `AgentContextMiddleware` in the `mlua-swarm`
569/// core crate (this crate stays execution-free; see the crate doc).
570/// Default (`include: None, exclude: vec![]`) is pass-all — [`Self::allows`]
571/// returns `true` for every field name.
572#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, JsonSchema)]
573#[serde(deny_unknown_fields)]
574pub struct ContextPolicy {
575    /// Field names to keep. `None` means "keep everything" (pass-all).
576    /// Matched against the `AgentContextView` named-field strings
577    /// (`"project_root"` / `"work_dir"` / `"task_metadata"` / `"run_id"` /
578    /// `"project_name_alias"`) and `extra` keys by their own key string.
579    /// Identity fields (`task_id` / `agent` / `attempt`) are never
580    /// filtered regardless of `include`.
581    #[serde(default)]
582    pub include: Option<Vec<String>>,
583    /// Field names to drop, applied AFTER `include` (exclude wins when a
584    /// name appears in both). Same name-matching rule as `include`.
585    #[serde(default)]
586    pub exclude: Vec<String>,
587    /// Which preceding steps' OUTPUT pointers a worker's fetch payload may
588    /// see (`WorkerPayload.context.steps`, ST5 of the `projection-adapter`
589    /// design). `None` = pass-all (every submitted step, the pre-ST5
590    /// `ctx_step_dir` behavior); `Some(list)` = only the named steps;
591    /// `Some(vec![])` = none. Evaluated by [`Self::allows_step`], a sibling
592    /// of [`Self::allows`] with the same include/exclude precedence rule
593    /// but a separate namespace (step names vs. `AgentContextView` field /
594    /// `extra` key names never collide).
595    #[serde(default)]
596    pub steps: Option<Vec<String>>,
597    /// Step names to drop, applied AFTER `steps` (exclude wins when a name
598    /// appears in both). Same name-matching rule as `steps`.
599    #[serde(default)]
600    pub steps_exclude: Vec<String>,
601}
602
603impl ContextPolicy {
604    /// Whether `name` survives this policy: `false` if `exclude` lists it;
605    /// otherwise `true` when `include` is `None` (pass-all) or lists
606    /// `name`. Shared by both the schema crate (tests) and the `mlua-swarm`
607    /// core crate's `AgentContextView::apply_policy`, so the include/exclude
608    /// evaluation rule has exactly one implementation.
609    pub fn allows(&self, name: &str) -> bool {
610        if self.exclude.iter().any(|excluded| excluded == name) {
611            return false;
612        }
613        match &self.include {
614            Some(list) => list.iter().any(|included| included == name),
615            None => true,
616        }
617    }
618
619    /// Whether the preceding step named `name` survives this policy for the
620    /// worker fetch payload's `context.steps` pointer list: `false` if
621    /// `steps_exclude` lists it; otherwise `true` when `steps` is `None`
622    /// (pass-all) or lists `name`. Same precedence rule as [`Self::allows`],
623    /// evaluated against the separate `steps` / `steps_exclude` fields.
624    pub fn allows_step(&self, name: &str) -> bool {
625        if self.steps_exclude.iter().any(|excluded| excluded == name) {
626            return false;
627        }
628        match &self.steps {
629            Some(list) => list.iter().any(|included| included == name),
630            None => true,
631        }
632    }
633}
634
635/// Global default `AgentKind` at the Schema impl Default layer. Bottom of the 4-layer cascade.
636pub fn default_global_agent_kind() -> AgentKind {
637    AgentKind::Operator
638}
639
640/// Set of **capability hint keys** for the SpawnerLayer required by a Blueprint.
641///
642/// # Design rationale (= for the person who will reconstruct this later)
643///
644/// A Blueprint is a pure layer of flow.ir + agent name binding and holds no middleware
645/// **implementation**. Nevertheless there are cases where the caller must be told the BP
646/// needs certain **capabilities** — e.g. "MainAI hook required", "Operator delegate path
647/// required", operator role mode switching, presence/absence of senior escalation, and
648/// so on.
649///
650/// `spawner_hints.layers` is the place where those capabilities are declared as **string
651/// keys**. The engine-side `LayerRegistry` (= consumer crate) resolves key → factory and
652/// wraps the compiled routes with a `SpawnerStack`. The Blueprint does not import the
653/// concrete `MainAIMiddleware` type; it exposes intent through strings such as `"main_ai"`
654/// (= separates the pure Flow layer from implementation details).
655///
656/// # Canonical hint keys
657///
658/// - `"main_ai"` → `MainAIMiddleware` (= fires SpawnHook before/after when kind is MainAi/Composite)
659/// - `"senior_escalation"` → `SeniorEscalationMiddleware` (= fires SeniorBridge.ask on worker ok=false)
660/// - `"operator_delegate"` → `OperatorDelegateMiddleware` (= delegates the entire spawn to an external Operator.execute)
661///
662/// # Behavior of unregistered keys
663///
664/// If the engine-side LayerRegistry has no matching factory, the key is **silently skipped**
665/// (= lenient default). This preserves Blueprint portability (= an unsupported capability in
666/// another deployment falls back gracefully).
667#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, JsonSchema)]
668#[serde(deny_unknown_fields)]
669pub struct SpawnerHints {
670    /// Ordered list of layer hint keys to wrap around the SpawnerStack.
671    #[serde(default)]
672    pub layers: Vec<String>,
673}
674
675// ──────────────────────────────────────────────────────────────────────────
676// AgentDef / AgentKind / AgentProfile / AgentMeta
677// ──────────────────────────────────────────────────────────────────────────
678
679/// Maps an agent name to a Worker IMPL kind and its configuration. Referenced from flow.ir
680/// `Step.ref` by name.
681///
682/// # Design
683///
684/// `AgentDef.kind` directly expresses the **Worker IMPL axis** (= not the old Spawner axis).
685/// Dispatching to a host Spawner adapter (`InProcSpawner` / `ProcessSpawner` /
686/// `OperatorSpawner`) is done by an internal Resolver on the compiler side. The design goal
687/// is "do not make the caller aware of which Spawner hosts the Worker IMPL"; the caller
688/// (Blueprint author) sees only the WorkerIMPL viewpoint.
689///
690/// A Spawner-axis hint (= "which adapter would you prefer running this Worker on", as a
691/// priority list) will be added via a future `spawner_hint: Vec<Spawner>` field as a carry.
692/// The current internal Resolver is a fixed 1:1 mapping, so the field is unnecessary today.
693#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, JsonSchema)]
694#[serde(deny_unknown_fields)]
695pub struct AgentDef {
696    /// Agent name (= referenced from flow.ir `Step.ref`).
697    pub name: String,
698    /// Worker IMPL kind (= see [`AgentKind`]).
699    pub kind: AgentKind,
700    /// Free-form schema per kind. Interpreted by the SpawnerFactory.
701    ///
702    /// Per-kind key contracts are documented on the [`AgentKind`] variants
703    /// (`fn_id` for `Lua` / `RustFn`, `program` + `args` for `Subprocess`,
704    /// `operator_ref` for `Operator`, and the `script_path` /
705    /// `project_root` / `mcp_rpc_timeout_ms` / `mcp_servers` set for
706    /// [`AgentKind::AgentBlock`]).
707    #[serde(default)]
708    pub spec: Value,
709    /// Agent persona information (system_prompt / model / tools, etc.). Orthogonal to the
710    /// backend kind and is a first-class field. Expected to be populated by
711    /// `agent_md_loader` from the frontmatter + body of an `agent.md`. `None` = an agent
712    /// without a profile (= backend built solely from `spec`).
713    #[serde(default)]
714    pub profile: Option<AgentProfile>,
715    /// Agent-level metadata (description / version / tags).
716    #[serde(default)]
717    pub meta: Option<AgentMeta>,
718    /// GH #46 M2 — inline [`Runner`] declaration: the highest-priority
719    /// tier of the [`resolve_runner`] cascade. `None` = this agent
720    /// declares no inline Runner (falls through to [`Self::runner_ref`],
721    /// then the legacy `profile.worker_binding` fallback, then
722    /// `Blueprint.default_runner`).
723    #[serde(default, skip_serializing_if = "Option::is_none")]
724    pub runner: Option<Runner>,
725    /// GH #46 M2 — a [`RunnerDef::name`] reference into
726    /// `Blueprint.runners` (second-priority tier of [`resolve_runner`]).
727    /// `None` = this agent declares no Runner registry reference.
728    #[serde(default, skip_serializing_if = "Option::is_none")]
729    pub runner_ref: Option<String>,
730    /// GH #50 — opt-in declaration of which OUTPUT channel this agent's
731    /// verdict token lives on, and the closed set of tokens it may emit
732    /// through that channel (see [`VerdictContract`]). Consumed by the
733    /// `mlua-swarm` core crate's `Compiler::compile` to lint
734    /// `Branch`/`Loop` `Eq`/`Ne`/`In` conds against this agent's output at
735    /// register time; a follow-up submit-time producer gate is a separate
736    /// enforcement point. `None` (the default) — this agent declares no
737    /// contract; a cond comparing its output to a literal is unchanged (at
738    /// most a `tracing::warn!`, never rejected) — every pre-GH-#50
739    /// Blueprint is unaffected, byte-for-byte.
740    #[serde(default, skip_serializing_if = "Option::is_none")]
741    pub verdict: Option<VerdictContract>,
742}
743
744/// Agent persona information. Orthogonal to the backend kind (Shell / InProc / Operator).
745///
746/// Populated by `agent_md_loader::load_dir` from the frontmatter and Markdown body of
747/// `agents/*.md` in agent-profiles. The backend (e.g. AgentBlockOperator) receives this
748/// struct at construction / dispatch time and consumes `system_prompt` as the LLM API
749/// system message and `model` / `tools` as configuration.
750///
751/// C-C-specific fields (`permissionMode` / `memory` / `abtest`, etc.) are dumped into
752/// `extras: Value`, and consumers that need them read them out. This is the escape hatch
753/// that keeps the schema future-proof rather than making it strict.
754#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default, JsonSchema)]
755#[serde(deny_unknown_fields)]
756pub struct AgentProfile {
757    /// Markdown body (= system prompt content).
758    #[serde(default)]
759    pub system_prompt: String,
760    /// LLM model identifier (e.g. `"sonnet"` / `"haiku"` / `"opus"`).
761    #[serde(default)]
762    pub model: Option<String>,
763    /// Reasoning effort (e.g. `"low"` / `"medium"` / `"high"`).
764    #[serde(default)]
765    pub effort: Option<String>,
766    /// List of available tool names (normalized from the CSV form in frontmatter).
767    #[serde(default)]
768    pub tools: Vec<String>,
769    /// Frontmatter `description`. A short one-line description.
770    #[serde(default)]
771    pub description: Option<String>,
772    /// C-C-specific / future-proof fields (permissionMode / memory / abtest / ...).
773    /// Shape is the leftover keys of the agent.md frontmatter dumped as a JSON object.
774    #[serde(default)]
775    pub extras: Value,
776    /// Content hash (blake3 32-byte hex) of the agent body (= `system_prompt`).
777    ///
778    /// # Purpose
779    ///
780    /// When the Enhance loop receives a Patch that replaces
781    /// `/agents/N/profile/system_prompt`, the post-hook in `patch_applier.lua`
782    /// recomputes this field (= new blake3 of the body) and updates it automatically.
783    /// This is the field that structurally prevents a Blueprint carrying a stale hash
784    /// from being committed.
785    ///
786    /// - `None` = hash not computed (= manually built agent, or a Blueprint predating this field)
787    /// - `Some(hex)` = latest hash at agent-profiles seed time or after PatchApplier
788    ///
789    /// Planned to be used as the cache-index key in `AgentStore`.
790    #[serde(default)]
791    pub version_hash: Option<String>,
792    /// Claude Code SubAgent definition name this agent binds to at spawn
793    /// time (e.g. "code-worker"). Why: the Blueprint is the single
794    /// source of truth for the declaration↔executor binding — an external
795    /// registry would duplicate what `tools` already declares and drift.
796    /// `None` is valid for agents whose operator backend never dispatches
797    /// a SubAgent (direct-LLM operators); WS thin-path operators require
798    /// it at compile time (see `Operator::requires_worker_binding`).
799    #[serde(default, skip_serializing_if = "Option::is_none")]
800    pub worker_binding: Option<String>,
801}
802
803/// SoT of the **Worker IMPL axis**. A closed enum managed inside Swarm and extended by
804/// variant addition through **explicit maintenance**. String lookup / escape hatches are
805/// deliberately not adopted.
806///
807/// This enum **expresses Worker IMPL directly**; dispatching to a host Spawner adapter is
808/// resolved by an internal Resolver on the compiler side (= callers see only the Worker
809/// IMPL viewpoint).
810///
811/// # Internal Resolver mapping (= currently a fixed 1:1, carry: priority list form)
812///
813/// | AgentKind | Host Spawner adapter |
814/// |---|---|
815/// | `Lua` | `InProcSpawner` (mlua VM eval) |
816/// | `RustFn` | `InProcSpawner` (Rust closure) |
817/// | `AgentBlock` | `InProcSpawner` (agent-block-core SDK in-process) |
818/// | `Subprocess` | `ProcessSpawner` (child process launch) |
819/// | `Operator` | `OperatorSpawner` (interactive role / Human-MainAI delegation) |
820#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash, JsonSchema)]
821#[serde(rename_all = "snake_case")]
822pub enum AgentKind {
823    /// Lua script eval through the mlua VM (= factory-side registry looked up by `spec.fn_id`).
824    Lua,
825    /// Rust closure (= factory-side registry looked up by `spec.fn_id`).
826    RustFn,
827    /// Headless LLM agent via the agent-block-core SDK (in-process).
828    ///
829    /// Pairs with a [`Runner::AgentBlockInProcess`]. Its `spec` contract
830    /// (GH #86 — every key optional):
831    ///
832    /// | key | meaning |
833    /// |---|---|
834    /// | `script_path` | Absent → **PromptBasedAgent** mode (the host embeds an invoker that calls the SDK's `agent` module). Present → **ScriptBasedAgent** mode (that Lua script runs instead). |
835    /// | `project_root` | Compile-time fallback cwd. Overridden per launch by `init_ctx.work_dir` / `init_ctx.project_root`. |
836    /// | `mcp_rpc_timeout_ms` | MCP RPC timeout; default `30000`. |
837    /// | `mcp_servers` | `[{name, command, args}]` pool the tool grant selects from. |
838    ///
839    /// The step's evaluated `in` reaches the agent as the `_PROMPT` Lua
840    /// global and `profile.system_prompt` as `_CONTEXT` — not through the
841    /// server process env. A script returns its result by calling
842    /// `bus.emit(<kind>, payload)`; the host reads `payload.content`, else
843    /// `payload.response`, else the whole payload, as the step OUTPUT body
844    /// (which is what a [`VerdictChannel::Body`] contract compares).
845    AgentBlock,
846    /// Child-process launch (= `spec.program` + `args`, via the ProcessSpawner path).
847    Subprocess,
848    /// Interactive Operator role (= MainAI / Human delegation, `spec.operator_ref`).
849    Operator,
850}
851
852// ──────────────────────────────────────────────────────────────────────────
853// VerdictContract / VerdictChannel (GH #50 — opt-in cond↔output-shape lint)
854// ──────────────────────────────────────────────────────────────────────────
855
856/// Opt-in per-agent declaration of the step OUTPUT shape a downstream
857/// `Branch`/`Loop` `cond` is allowed to structurally compare against — see
858/// the `blueprint-authoring.md` guide's "Returning verdicts to drive BP
859/// flow" section for the Pattern A/B shapes this mirrors. Consumed by the
860/// `mlua-swarm` core crate's `Compiler::compile` (a register-time,
861/// read-only lint over `Branch`/`Loop` `Eq`/`Ne`/`In` conds — no `flow`
862/// rewriting, no new `Expr` forms) and, as a follow-up, by the server's
863/// submit-time producer gate. `None` on [`AgentDef::verdict`] (the
864/// default) means neither enforcement point runs for that agent — the
865/// pre-GH-#50 behavior, byte-for-byte.
866#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, JsonSchema)]
867#[serde(deny_unknown_fields)]
868pub struct VerdictContract {
869    /// Which OUTPUT channel carries the verdict token — see
870    /// [`VerdictChannel`].
871    pub channel: VerdictChannel,
872    /// Closed set of the verdict tokens this agent may emit through the
873    /// declared `channel` (e.g. `["PASS", "BLOCKED"]`). A `Branch`/`Loop`
874    /// cond's `Lit` operand(s) compared against this agent's declared
875    /// channel must be members of this set.
876    pub values: Vec<String>,
877}
878
879/// Which step OUTPUT channel a [`VerdictContract`] addresses — the two
880/// canonical submit shapes documented in the `blueprint-authoring.md`
881/// guide's "Returning verdicts to drive BP flow" section.
882#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
883#[serde(rename_all = "lowercase")]
884pub enum VerdictChannel {
885    /// Pattern A — the plain step OUTPUT body IS the verdict scalar; a cond
886    /// addresses it as the bare step output (`$.<step>`).
887    Body,
888    /// Pattern B — the verdict is staged as the named part `"verdict"`
889    /// alongside a separate plain-body report; a cond addresses it as
890    /// `$.<step>.parts.verdict` (equivalently `$.<step>.parts["verdict"]`
891    /// — both forms normalize to the same canonical [`Path`](mlua_flow_ir::Path) `Display`).
892    Part,
893}
894
895// ──────────────────────────────────────────────────────────────────────────
896// Runner / RunnerDef / WorkerModel / resolve_runner (GH #46 Milestone 2)
897// ──────────────────────────────────────────────────────────────────────────
898
899/// The execution shell an agent's Worker IMPL runs inside — holding tool
900/// grant, model selection, and runtime capabilities. Tier 1 of the GH #46
901/// 3-tier Worker model (Runner / Agent / Context).
902///
903/// Runner here is broader than the ADK / OpenAI Agents SDK Runner (a loop
904/// driver): it is the execution shell holding tool grant, model
905/// selection, and runtime capabilities. Loop driving itself is the
906/// backend's job (Claude Code harness / AgentBlock runtime).
907///
908/// Resolved per-agent by [`resolve_runner`]'s 5-step cascade; wiring the
909/// resolved value into the launch path is Milestone 3 — this Milestone
910/// only declares the shape and the pure resolver.
911#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
912#[serde(tag = "backend", rename_all = "snake_case", deny_unknown_fields)]
913pub enum Runner {
914    /// Platform-neutral WebSocket Operator backend. The joined execution
915    /// environment may be Claude Code, Codex, or another MainAI/plugin that
916    /// implements the common binding and spawn contracts.
917    WsOperator {
918        /// Provider-defined launch variant selected by the execution environment.
919        variant: String,
920        /// Minimum tool grant the provider must enforce.
921        #[serde(default, skip_serializing_if = "Vec::is_empty")]
922        tools: Vec<String>,
923    },
924    /// WS backend: Claude Code subagent wrapper. `variant` is the
925    /// wrapper's subagent_type; `tools` mirrors the wrapper frontmatter =
926    /// enforced grant.
927    ///
928    /// Kept as a compatibility backend for existing Blueprints. New
929    /// platform-neutral declarations should use [`Self::WsOperator`].
930    WsClaudeCode {
931        /// The wrapper's `subagent_type` (= `WorkerBinding.variant` in the
932        /// `mlua-swarm` core crate).
933        variant: String,
934        /// Declared (informational) tool list — mirrors the wrapper
935        /// frontmatter; the actual grant is enforced by the wrapper file
936        /// itself, not by this list.
937        #[serde(default, skip_serializing_if = "Vec::is_empty")]
938        tools: Vec<String>,
939    },
940    /// In-process backend: agent-block runtime. `tools` is the effective
941    /// (enforced) tool set for the in-process registry.
942    ///
943    /// Enforcement is per [`AgentKind::AgentBlock`] mode (GH #86) and is
944    /// **server-granular**: PromptBasedAgent embeds only the
945    /// `spec.mcp_servers` entries named by an `mcp__<server>__<tool>` entry
946    /// of this list, so an unlisted server is unreachable — but every tool
947    /// of a listed server is reachable. ScriptBasedAgent
948    /// (`spec.script_path` present) cannot be enforced at all — the script
949    /// drives its own `mcp.connect` — so declaring `mcp__` entries there is
950    /// a compile error rather than a silent no-op.
951    AgentBlockInProcess {
952        /// Effective (enforced) tool set passed to the agent-block
953        /// runtime's registry — unlike WebSocket Runner tool requests, this
954        /// list is not merely informational.
955        ///
956        /// Declaring this Runner at all overrides `profile.tools`,
957        /// **including when the list is empty**: an empty list is an
958        /// enforced-empty grant (the way a Blueprint revokes an agent.md's
959        /// inherited `tools:` line), not "unset". An agent that declares no
960        /// Runner keeps `profile.tools` as its effective set. The override
961        /// is applied once, when the Run's immutable `BoundAgent` snapshot
962        /// is projected for the compiler, so it is pinned for resume.
963        #[serde(default, skip_serializing_if = "Vec::is_empty")]
964        tools: Vec<String>,
965    },
966    /// GH #83 — Subprocess EmbedAgent backend: the step runs headless
967    /// through the `ProcessSpawner` path, with the invocation described by
968    /// a [`SubprocessDef`] template looked up by name in
969    /// [`Blueprint::subprocesses`]. Name symmetry with
970    /// `AgentKind::Subprocess` is deliberate (1:1 — this variant is the
971    /// Runner-axis face of the same Worker IMPL kind).
972    ///
973    /// Per-agent overrides live HERE (not on `SubprocessDef`) so the
974    /// template struct stays flat and shareable across agents.
975    Subprocess {
976        /// [`SubprocessDef::name`] reference into
977        /// [`Blueprint::subprocesses`].
978        template: String,
979        /// Per-agent overrides applied on top of the referenced template
980        /// and the agent profile. Empty (all defaults) is omitted on the
981        /// wire.
982        #[serde(default, skip_serializing_if = "SubprocessOverrides::is_empty")]
983        overrides: SubprocessOverrides,
984    },
985}
986
987/// Per-agent overrides for [`Runner::Subprocess`] — values that take
988/// precedence over the agent's `profile.model` / `profile.tools` and the
989/// spawn-time `{work_dir}` placeholder source when rendering the
990/// [`SubprocessDef`] template. Lives on the Runner variant (not on
991/// `SubprocessDef`) so the template itself stays flat (no per-agent
992/// state, no variant axis).
993#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
994#[serde(deny_unknown_fields)]
995pub struct SubprocessOverrides {
996    /// Overrides the `{model}` placeholder value (wins over
997    /// `profile.model`).
998    #[serde(default, skip_serializing_if = "Option::is_none")]
999    pub model: Option<String>,
1000    /// Overrides the `{tools_csv}` placeholder value (wins over
1001    /// `profile.tools`).
1002    #[serde(default, skip_serializing_if = "Vec::is_empty")]
1003    pub tools: Vec<String>,
1004    /// Overrides the child process working directory (wins over the
1005    /// template's `cwd` and the spawn-time `{work_dir}` source).
1006    #[serde(default, skip_serializing_if = "Option::is_none")]
1007    pub cwd: Option<String>,
1008}
1009
1010impl SubprocessOverrides {
1011    /// `true` when every field is at its default — used by
1012    /// `skip_serializing_if` so an empty overrides block stays off the
1013    /// wire (pre-#83 byte-compatibility for the `Runner` enum).
1014    pub fn is_empty(&self) -> bool {
1015        self.model.is_none() && self.tools.is_empty() && self.cwd.is_none()
1016    }
1017}
1018
1019/// GH #83 — one declarative CLI invocation template: how a materialized
1020/// worker payload (system prompt + task + model/tools/cwd) is rendered
1021/// into a child-process invocation, and how its stdout is normalized back
1022/// into the worker-result shape.
1023///
1024/// Deliberately a **flat struct** — no internal variant/kind
1025/// discriminator. Adding support for a new CLI backend means adding one
1026/// more named entry to [`Blueprint::subprocesses`], never a new enum arm
1027/// or spawner branch (the `AgentKind` closed enum already owns the kind
1028/// axis; nesting a second "backend" hierarchy under it is the exact
1029/// complexity this shape refuses).
1030///
1031/// `argv` / `stdin` / `env` values / `cwd` may contain `{placeholder}`
1032/// tokens drawn from a closed, logic-free set (`{system}` /
1033/// `{system_file}` / `{prompt}` / `{model}` / `{tools_csv}` /
1034/// `{work_dir}` / `{task_id}` / `{attempt}`). Rendering is pure string
1035/// substitution — no conditionals, no loops, no expression language; the
1036/// engine-side consumer validates tokens against the closed set at
1037/// compile time. This crate stores the templates as plain strings only
1038/// (IN-immutability: no execution logic here).
1039#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
1040#[serde(deny_unknown_fields)]
1041pub struct SubprocessDef {
1042    /// Registry key, referenced by `Runner::Subprocess { template }`.
1043    pub name: String,
1044    /// Program + arguments. `argv[0]` is the binary; every element may
1045    /// carry placeholder tokens.
1046    pub argv: Vec<String>,
1047    /// Rendered and piped to the child's stdin when `Some`; `None` = no
1048    /// stdin write (EOF immediately).
1049    #[serde(default, skip_serializing_if = "Option::is_none")]
1050    pub stdin: Option<String>,
1051    /// Extra environment variables (appended to the engine's `MSE_*`
1052    /// token exports). Values may carry placeholder tokens. `BTreeMap`
1053    /// for a deterministic wire order.
1054    #[serde(default, skip_serializing_if = "std::collections::BTreeMap::is_empty")]
1055    pub env: std::collections::BTreeMap<String, String>,
1056    /// Child working directory (may carry placeholder tokens). `None` =
1057    /// the spawn-time `{work_dir}` source decides (or the engine default
1058    /// when no source exists).
1059    #[serde(default, skip_serializing_if = "Option::is_none")]
1060    pub cwd: Option<String>,
1061    /// stdout normalization declaration. `None` = the engine's historical
1062    /// JSON-or-raw behavior, byte-for-byte.
1063    #[serde(default, skip_serializing_if = "Option::is_none")]
1064    pub output: Option<SubprocessOutput>,
1065    /// Streaming wire protocol for stdout (`"ndjson_lines"` /
1066    /// `"sse_events"` / `"length_prefixed"` — same vocabulary as the
1067    /// spec-based Subprocess path). `None` = plain mode.
1068    #[serde(default, skip_serializing_if = "Option::is_none")]
1069    pub stream_mode: Option<String>,
1070}
1071
1072/// GH #83 — declarative stdout → worker-result normalization for a
1073/// [`SubprocessDef`] (plain mode only; streaming modes keep their event
1074/// protocol untouched).
1075#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
1076#[serde(deny_unknown_fields)]
1077pub struct SubprocessOutput {
1078    /// Expected stdout format. `Some("json")` = stdout MUST parse as
1079    /// JSON (an unparsable stdout is a failed step). `None` = the
1080    /// historical lenient JSON-or-raw wrap.
1081    #[serde(default, skip_serializing_if = "Option::is_none")]
1082    pub format: Option<String>,
1083    /// JSON Pointer (RFC 6901) selecting the worker-result value out of
1084    /// the parsed stdout (e.g. `"/result"`). `None` = the whole parsed
1085    /// value.
1086    #[serde(default, skip_serializing_if = "Option::is_none")]
1087    pub result_ptr: Option<String>,
1088    /// Where the ok/failure signal comes from: `"exit_code"` (default
1089    /// behavior) or a JSON Pointer into the parsed stdout whose value
1090    /// must be boolean `true` for ok.
1091    #[serde(default, skip_serializing_if = "Option::is_none")]
1092    pub ok_from: Option<String>,
1093    /// Declarative stdout → per-step worker-stats mapping (token usage
1094    /// / model / num_turns), applied by the engine after a successful
1095    /// JSON parse. `None` = no stats extraction (the engine still
1096    /// records exit code + declared model as baseline stats).
1097    #[serde(default, skip_serializing_if = "Option::is_none")]
1098    pub stats: Option<SubprocessStats>,
1099}
1100
1101/// Declarative stats extraction for a [`SubprocessOutput`] — JSON
1102/// Pointers (RFC 6901) into the parsed stdout, mirroring the
1103/// `result_ptr` idiom. Lets a declared CLI backend (e.g. `claude -p
1104/// --output-format json`, `codex exec --json`) surface token usage
1105/// without any engine-side backend branch. Same IN-immutability
1106/// discipline as the rest of this crate: pointers only, no logic.
1107#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
1108#[serde(deny_unknown_fields)]
1109pub struct SubprocessStats {
1110    /// JSON Pointer selecting a usage OBJECT out of the parsed stdout.
1111    /// The engine reads `input_tokens`/`output_tokens` (falling back to
1112    /// the OpenAI-style `prompt_tokens`/`completion_tokens` spelling)
1113    /// and an optional `total_tokens` from it.
1114    #[serde(default, skip_serializing_if = "Option::is_none")]
1115    pub usage_ptr: Option<String>,
1116    /// JSON Pointer selecting the model name STRING that actually
1117    /// served the run (overrides the template's declared `{model}`
1118    /// value in the recorded stats when present).
1119    #[serde(default, skip_serializing_if = "Option::is_none")]
1120    pub model_ptr: Option<String>,
1121    /// JSON Pointer selecting the number of LLM turns (a JSON number).
1122    #[serde(default, skip_serializing_if = "Option::is_none")]
1123    pub num_turns_ptr: Option<String>,
1124}
1125
1126/// One [`Blueprint::runners`] registry entry — a named [`Runner`]
1127/// declaration referenced by `AgentDef.runner_ref` /
1128/// [`Blueprint::default_runner`]. Same registry shape as [`MetaDef`] (GH
1129/// #21 Phase 2).
1130#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
1131#[serde(deny_unknown_fields)]
1132pub struct RunnerDef {
1133    /// Registry key, referenced by `AgentDef.runner_ref` /
1134    /// `Blueprint.default_runner`.
1135    pub name: String,
1136    /// The declared Runner.
1137    pub runner: Runner,
1138}
1139
1140/// Canonical GH #46 Worker unit: a resolved [`Runner`] paired with the
1141/// [`AgentDef`] it backs. The Milestone 4 adapter is the consumer that
1142/// turns this into a runtime spawn; this crate only declares the shape
1143/// (no execution logic lives here — see the crate doc's IN-immutability
1144/// discipline).
1145#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
1146#[serde(deny_unknown_fields)]
1147pub struct WorkerModel {
1148    /// The resolved Runner.
1149    pub runner: Runner,
1150    /// The agent this Runner backs.
1151    pub agent: AgentDef,
1152}
1153
1154/// Everything [`resolve_runner`] can fail with: an `AgentDef.runner_ref`
1155/// / `Blueprint.default_runner` reference that names no entry in
1156/// `Blueprint.runners`.
1157#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
1158pub enum RunnerResolveError {
1159    /// `AgentDef.runner_ref` names a [`RunnerDef::name`] absent from
1160    /// `Blueprint.runners`.
1161    #[error(
1162        "agent '{agent}' runner_ref '{ref_name}' does not match any RunnerDef.name in \
1163         Blueprint.runners (defined: {available:?})"
1164    )]
1165    UnknownRunnerRef {
1166        /// The agent whose `runner_ref` didn't resolve.
1167        agent: String,
1168        /// The `runner_ref` value that was looked up.
1169        ref_name: String,
1170        /// The `RunnerDef.name`s that *are* declared, for the error message.
1171        available: Vec<String>,
1172    },
1173    /// `Blueprint.default_runner` names a [`RunnerDef::name`] absent from
1174    /// `Blueprint.runners`.
1175    #[error(
1176        "default_runner '{ref_name}' does not match any RunnerDef.name in Blueprint.runners \
1177         (defined: {available:?})"
1178    )]
1179    UnknownDefaultRunner {
1180        /// The `default_runner` value that was looked up.
1181        ref_name: String,
1182        /// The `RunnerDef.name`s that *are* declared, for the error message.
1183        available: Vec<String>,
1184    },
1185}
1186
1187/// Resolve `agent`'s effective [`Runner`] against `bp`, in cascade order
1188/// (highest priority first):
1189///
1190/// 1. `agent.runner` (inline declaration) — wins unconditionally.
1191/// 2. `agent.runner_ref`, resolved against `bp.runners` (an unresolved
1192///    name is [`RunnerResolveError::UnknownRunnerRef`]).
1193/// 3. Legacy fallback (agent-level): `agent.profile.worker_binding =
1194///    Some(variant)` becomes `Runner::WsClaudeCode { variant,
1195///    tools: profile.tools.clone() }` — the same synthesis
1196///    `crate::service::task_launch::derive_worker_bindings` (in the
1197///    `mlua-swarm` core crate) performs at launch time today.
1198/// 4. `bp.default_runner`, resolved against `bp.runners` (an unresolved
1199///    name is [`RunnerResolveError::UnknownDefaultRunner`]).
1200/// 5. `Ok(None)` — no Runner declared through any tier.
1201///
1202/// **Legacy (agent-level) beats `default_runner` (BP-global)**: tier 3
1203/// outranks tier 4, the same "agent-level wins over BP-global" rule the
1204/// ctx cascade (`AgentInline > MetaRef > BpGlobal`, see
1205/// `mlua-swarm`'s `core::explain::CtxTier`) already follows.
1206///
1207/// Pure and read-only: this Milestone does not wire the result into the
1208/// launch / compile path (Milestone 3 scope) — it only declares the
1209/// resolver.
1210pub fn resolve_runner(
1211    bp: &Blueprint,
1212    agent: &AgentDef,
1213) -> Result<Option<Runner>, RunnerResolveError> {
1214    // 1. inline — wins unconditionally.
1215    if let Some(runner) = &agent.runner {
1216        return Ok(Some(runner.clone()));
1217    }
1218
1219    // 2. runner_ref → bp.runners lookup.
1220    if let Some(ref_name) = &agent.runner_ref {
1221        return match bp.runners.iter().find(|def| &def.name == ref_name) {
1222            Some(def) => Ok(Some(def.runner.clone())),
1223            None => Err(RunnerResolveError::UnknownRunnerRef {
1224                agent: agent.name.clone(),
1225                ref_name: ref_name.clone(),
1226                available: bp.runners.iter().map(|d| d.name.clone()).collect(),
1227            }),
1228        };
1229    }
1230
1231    // 3. legacy fallback (agent-level `profile.worker_binding`) — outranks
1232    // `bp.default_runner` (tier 4).
1233    if let Some(variant) = agent
1234        .profile
1235        .as_ref()
1236        .and_then(|p| p.worker_binding.as_ref())
1237    {
1238        let tools = agent
1239            .profile
1240            .as_ref()
1241            .map(|p| p.tools.clone())
1242            .unwrap_or_default();
1243        return Ok(Some(Runner::WsClaudeCode {
1244            variant: variant.clone(),
1245            tools,
1246        }));
1247    }
1248
1249    // 4. bp.default_runner → bp.runners lookup.
1250    if let Some(ref_name) = &bp.default_runner {
1251        return match bp.runners.iter().find(|def| &def.name == ref_name) {
1252            Some(def) => Ok(Some(def.runner.clone())),
1253            None => Err(RunnerResolveError::UnknownDefaultRunner {
1254                ref_name: ref_name.clone(),
1255                available: bp.runners.iter().map(|d| d.name.clone()).collect(),
1256            }),
1257        };
1258    }
1259
1260    // 5. nothing declared through any tier.
1261    Ok(None)
1262}
1263
1264/// Which declaration tier supplied a [`BoundAgent`]'s resolved Runner.
1265/// Kept in the immutable snapshot so explain surfaces can distinguish a
1266/// first-class binding from the Claude Code compatibility fallback.
1267#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
1268#[serde(rename_all = "snake_case")]
1269pub enum RunnerResolutionSource {
1270    /// `AgentDef.runner`.
1271    AgentInline,
1272    /// `AgentDef.runner_ref` resolved through `Blueprint.runners`.
1273    AgentRef,
1274    /// Deprecated `AgentProfile.worker_binding` compatibility path.
1275    LegacyWorkerBinding,
1276    /// `Blueprint.default_runner` resolved through `Blueprint.runners`.
1277    BlueprintDefault,
1278    /// No Runner applies to this in-process or otherwise unbound agent.
1279    None,
1280}
1281
1282/// Strongly typed identity of one immutable [`BoundAgent`] snapshot.
1283/// Transparent serde keeps the public JSON wire form a plain string.
1284#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, JsonSchema)]
1285#[serde(transparent)]
1286pub struct BindingDigest(String);
1287
1288impl BindingDigest {
1289    /// Compute the canonical `sha256:<lowercase-hex>` digest of `bytes`.
1290    pub fn sha256(bytes: impl AsRef<[u8]>) -> Self {
1291        use sha2::Digest as _;
1292        Self(format!(
1293            "sha256:{}",
1294            hex::encode(sha2::Sha256::digest(bytes.as_ref()))
1295        ))
1296    }
1297
1298    /// Borrow the stable wire representation.
1299    pub fn as_str(&self) -> &str {
1300        &self.0
1301    }
1302}
1303
1304impl std::fmt::Display for BindingDigest {
1305    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1306        f.write_str(&self.0)
1307    }
1308}
1309
1310impl std::str::FromStr for BindingDigest {
1311    type Err = BindingDigestParseError;
1312
1313    fn from_str(value: &str) -> Result<Self, Self::Err> {
1314        let Some(hex_part) = value.strip_prefix("sha256:") else {
1315            return Err(BindingDigestParseError::InvalidFormat(value.to_string()));
1316        };
1317        let canonical = hex_part.len() == 64
1318            && hex_part
1319                .bytes()
1320                .all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b));
1321        if !canonical {
1322            return Err(BindingDigestParseError::InvalidFormat(value.to_string()));
1323        }
1324        Ok(Self(value.to_string()))
1325    }
1326}
1327
1328impl<'de> Deserialize<'de> for BindingDigest {
1329    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
1330    where
1331        D: serde::Deserializer<'de>,
1332    {
1333        use std::str::FromStr as _;
1334        let value = String::deserialize(deserializer)?;
1335        Self::from_str(&value).map_err(serde::de::Error::custom)
1336    }
1337}
1338
1339/// Rejection returned when an external binding digest is not in canonical
1340/// `sha256:<64 lowercase hex>` form.
1341#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
1342pub enum BindingDigestParseError {
1343    /// Unsupported algorithm prefix, wrong length, uppercase, or non-hex.
1344    #[error("invalid binding digest '{0}'; expected sha256:<64 lowercase hex>")]
1345    InvalidFormat(String),
1346}
1347
1348/// Platform-neutral request sent to an [`AgentBindingProvider`](https://docs.rs/mlua-swarm)
1349/// before a Run is dispatched.
1350///
1351/// The request contains only Swarm declarations. A provider may resolve
1352/// platform aliases or inspect its own execution environment, but Swarm
1353/// validates the returned [`BindReceipt`] before accepting it.
1354#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
1355#[serde(deny_unknown_fields)]
1356pub struct BindRequest {
1357    /// Logical agent name; the receipt correlation key.
1358    pub agent: String,
1359    /// Digest of the declaration-only [`BoundAgent`] snapshot.
1360    pub request_digest: BindingDigest,
1361    /// Runner backend family Core resolved for this agent.
1362    pub backend: BindingBackend,
1363    /// Provider-specific routing key. For Operator-backed runners this is
1364    /// the logical `operator_ref`, never a runtime session id.
1365    #[serde(default, skip_serializing_if = "Option::is_none")]
1366    pub binding_target: Option<String>,
1367    /// Requested model name or tier from [`AgentProfile::model`].
1368    #[serde(default, skip_serializing_if = "Option::is_none")]
1369    pub requested_model: Option<String>,
1370    /// Minimum tool grant declared by the resolved [`Runner`].
1371    #[serde(default, skip_serializing_if = "Vec::is_empty")]
1372    pub requested_tools: Vec<String>,
1373    /// Platform launch variant requested by the resolved [`Runner`].
1374    #[serde(default, skip_serializing_if = "Option::is_none")]
1375    pub launch_variant: Option<String>,
1376}
1377
1378/// Backend family a binding provider must resolve.
1379#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
1380#[serde(rename_all = "snake_case")]
1381pub enum BindingBackend {
1382    /// Platform-neutral Operator/MainAI WebSocket execution.
1383    WsOperator,
1384    /// Claude Code wrapper dispatched through an Operator WebSocket.
1385    WsClaudeCode,
1386    /// AgentBlock registry enforced in the Server process.
1387    AgentBlockInProcess,
1388}
1389
1390/// Provider report describing the effective runtime binding for one agent.
1391/// This value is untrusted until Swarm validates it against [`BindRequest`].
1392#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
1393#[serde(deny_unknown_fields)]
1394pub struct BindReceipt {
1395    /// Logical agent name copied from the request.
1396    pub agent: String,
1397    /// Declaration digest copied from the request. Core rejects stale or
1398    /// cross-request receipts even when the logical agent name matches.
1399    pub request_digest: BindingDigest,
1400    /// Stable provider implementation identifier.
1401    pub provider_id: String,
1402    /// Provider or adapter revision used to resolve the binding.
1403    #[serde(default, skip_serializing_if = "Option::is_none")]
1404    pub provider_revision: Option<String>,
1405    /// Effective model after platform alias/tier resolution.
1406    #[serde(default, skip_serializing_if = "Option::is_none")]
1407    pub resolved_model: Option<String>,
1408    /// Effective tool grant enforced by the execution environment.
1409    #[serde(default, skip_serializing_if = "Vec::is_empty")]
1410    pub effective_tools: Vec<String>,
1411    /// Effective platform launch variant.
1412    #[serde(default, skip_serializing_if = "Option::is_none")]
1413    pub launch_variant: Option<String>,
1414    /// Optional digest of the provider-observed capability snapshot. This is
1415    /// a drift/lint correlation key, not independent security evidence.
1416    #[serde(
1417        default,
1418        alias = "evidence_digest",
1419        skip_serializing_if = "Option::is_none"
1420    )]
1421    pub capability_snapshot_digest: Option<BindingDigest>,
1422}
1423
1424/// One provider outcome for a single [`BindRequest`].
1425///
1426/// A provider reports exactly one outcome per requested agent. `Bound`
1427/// carries an (untrusted) [`BindReceipt`] Core still validates; `Unbound`
1428/// records that the execution environment currently offers no capability for
1429/// the request (e.g. the role has not joined, or the manifest declares no
1430/// matching launch variant). Whether an `Unbound` outcome fails the launch
1431/// or is merely observed is decided by
1432/// [`CompilerStrategy::strict_binding`] — not by the provider.
1433#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
1434#[serde(tag = "outcome", rename_all = "snake_case", deny_unknown_fields)]
1435pub enum BindOutcome {
1436    /// The provider resolved a receipt for the agent. Still untrusted until
1437    /// Core validates it against the originating [`BindRequest`].
1438    Bound {
1439        /// Provider-reported binding, validated by Core before acceptance.
1440        receipt: BindReceipt,
1441    },
1442    /// The provider offers no capability for the request right now. The
1443    /// `reason` is human-facing diagnostic text only; it never enters the
1444    /// [`BoundAgent`] snapshot or its digest lineage.
1445    Unbound {
1446        /// Logical agent name copied from the request.
1447        agent: String,
1448        /// Why the provider could not bind the agent.
1449        reason: String,
1450    },
1451}
1452
1453/// Core-validated capability statement pinned into a [`BoundAgent`].
1454///
1455/// It deliberately omits the logical agent name because the containing
1456/// snapshot already supplies that identity.
1457#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
1458#[serde(deny_unknown_fields)]
1459pub struct BindingAttestation {
1460    /// Declaration-only digest the provider attested.
1461    pub request_digest: BindingDigest,
1462    /// Stable provider implementation identifier.
1463    pub provider_id: String,
1464    /// Provider or adapter revision used to resolve the binding.
1465    #[serde(default, skip_serializing_if = "Option::is_none")]
1466    pub provider_revision: Option<String>,
1467    /// Effective model after platform alias/tier resolution.
1468    #[serde(default, skip_serializing_if = "Option::is_none")]
1469    pub resolved_model: Option<String>,
1470    /// Effective tool grant, canonicalized by Swarm.
1471    #[serde(default, skip_serializing_if = "Vec::is_empty")]
1472    pub effective_tools: Vec<String>,
1473    /// Effective platform launch variant.
1474    #[serde(default, skip_serializing_if = "Option::is_none")]
1475    pub launch_variant: Option<String>,
1476    /// Optional digest of the provider-observed capability snapshot.
1477    #[serde(
1478        default,
1479        alias = "evidence_digest",
1480        skip_serializing_if = "Option::is_none"
1481    )]
1482    pub capability_snapshot_digest: Option<BindingDigest>,
1483}
1484
1485/// One effective capability advertised by an execution-environment
1486/// provider. Operator manifests normally publish one entry per wrapper
1487/// variant.
1488#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
1489#[serde(deny_unknown_fields)]
1490pub struct AgentProviderCapability {
1491    /// Platform launch variant this capability serves. `None` is reserved
1492    /// for backends without a variant axis.
1493    #[serde(default, skip_serializing_if = "Option::is_none")]
1494    pub launch_variant: Option<String>,
1495    /// Effective model selected by the provider.
1496    #[serde(default, skip_serializing_if = "Option::is_none")]
1497    pub resolved_model: Option<String>,
1498    /// Effective tool grant enforced by the provider.
1499    #[serde(default, skip_serializing_if = "Vec::is_empty")]
1500    pub effective_tools: Vec<String>,
1501    /// Optional digest of the provider-observed capability snapshot.
1502    #[serde(
1503        default,
1504        alias = "evidence_digest",
1505        skip_serializing_if = "Option::is_none"
1506    )]
1507    pub capability_snapshot_digest: Option<BindingDigest>,
1508}
1509
1510/// Capability manifest supplied by an Operator/MainAI or an official
1511/// execution-platform plugin when joining the Server.
1512#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
1513#[serde(deny_unknown_fields)]
1514pub struct AgentProviderManifest {
1515    /// Stable provider implementation identifier.
1516    pub provider_id: String,
1517    /// Provider or adapter revision used to inspect capabilities.
1518    #[serde(default, skip_serializing_if = "Option::is_none")]
1519    pub provider_revision: Option<String>,
1520    /// Effective capabilities available through this provider instance.
1521    #[serde(default, skip_serializing_if = "Vec::is_empty")]
1522    pub capabilities: Vec<AgentProviderCapability>,
1523}
1524
1525/// Immutable, Run-scoped result of binding the Runner / Agent / Context
1526/// layers for one logical agent.
1527///
1528/// This is derived state, not a fourth authoring source of truth. The full
1529/// [`AgentDef`] is retained deliberately: resume/replay must not re-read a
1530/// changed role prompt or result contract from a mutable Blueprint registry.
1531/// Capability attestation is adapter-owned and is therefore not guessed here;
1532/// the resolved [`Runner`] remains a declaration until an adapter records its
1533/// requested/effective comparison.
1534#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
1535#[serde(deny_unknown_fields)]
1536pub struct BoundAgent {
1537    /// Logical agent definition pinned for the Run.
1538    pub agent: AgentDef,
1539    /// Runner selected by [`resolve_runner`], if this agent needs one.
1540    #[serde(default, skip_serializing_if = "Option::is_none")]
1541    pub runner: Option<Runner>,
1542    /// Effective static Context policy (`AgentMeta.context_policy` wins over
1543    /// `Blueprint.default_context_policy`). Runtime context values are not
1544    /// embedded here.
1545    #[serde(default, skip_serializing_if = "Option::is_none")]
1546    pub context_policy: Option<ContextPolicy>,
1547    /// Declaration tier that supplied `runner`.
1548    pub runner_source: RunnerResolutionSource,
1549    /// Effective capability statement accepted from the injected binding
1550    /// provider. `None` preserves the declaration-only compatibility path.
1551    #[serde(default, skip_serializing_if = "Option::is_none")]
1552    pub attestation: Option<BindingAttestation>,
1553    /// SHA-256 over the other fields of this snapshot, prefixed with
1554    /// `sha256:`. This is replay identity and an observability correlation
1555    /// key, not a signature.
1556    pub binding_digest: BindingDigest,
1557}
1558
1559/// Failure while constructing immutable [`BoundAgent`] snapshots.
1560#[derive(Debug, thiserror::Error)]
1561pub enum BoundAgentResolveError {
1562    /// A Runner reference did not resolve.
1563    #[error(transparent)]
1564    Runner(#[from] RunnerResolveError),
1565    /// The snapshot input could not be serialized for deterministic hashing.
1566    #[error("bound agent '{agent}' could not be serialized for digest: {source}")]
1567    Digest {
1568        /// Logical agent name.
1569        agent: String,
1570        /// Serialization failure.
1571        source: serde_json::Error,
1572    },
1573    /// Strict binding rejected the deprecated Claude Code compatibility
1574    /// declaration instead of silently accepting it.
1575    #[error(
1576        "agent '{agent}' uses deprecated profile.worker_binding; strict binding requires runner or runner_ref"
1577    )]
1578    LegacyWorkerBindingDisabled {
1579        /// Logical agent that must be migrated.
1580        agent: String,
1581    },
1582}
1583
1584#[derive(Serialize)]
1585struct BoundAgentDigestInput<'a> {
1586    agent: &'a AgentDef,
1587    runner: &'a Option<Runner>,
1588    context_policy: &'a Option<ContextPolicy>,
1589    runner_source: RunnerResolutionSource,
1590    attestation: &'a Option<BindingAttestation>,
1591}
1592
1593impl BoundAgent {
1594    /// Replace the effective capability attestation and recompute replay
1595    /// identity over the complete immutable snapshot.
1596    pub fn set_attestation(
1597        &mut self,
1598        attestation: BindingAttestation,
1599    ) -> Result<(), BoundAgentResolveError> {
1600        self.attestation = Some(attestation);
1601        self.recompute_binding_digest()
1602    }
1603
1604    /// Recompute `binding_digest` after a trusted snapshot mutation.
1605    pub fn recompute_binding_digest(&mut self) -> Result<(), BoundAgentResolveError> {
1606        let digest_input = BoundAgentDigestInput {
1607            agent: &self.agent,
1608            runner: &self.runner,
1609            context_policy: &self.context_policy,
1610            runner_source: self.runner_source,
1611            attestation: &self.attestation,
1612        };
1613        let bytes =
1614            serde_json::to_vec(&digest_input).map_err(|source| BoundAgentResolveError::Digest {
1615                agent: self.agent.name.clone(),
1616                source,
1617            })?;
1618        self.binding_digest = BindingDigest::sha256(bytes);
1619        Ok(())
1620    }
1621}
1622
1623/// Resolve every `Blueprint.agents` entry into an immutable Run snapshot.
1624/// Output order follows `Blueprint.agents`, making persistence and explain
1625/// responses stable without a second sort.
1626pub fn resolve_bound_agents(bp: &Blueprint) -> Result<Vec<BoundAgent>, BoundAgentResolveError> {
1627    resolve_bound_agents_with_legacy(bp, true)
1628}
1629
1630/// Strict counterpart to [`resolve_bound_agents`]: rejects the deprecated
1631/// `profile.worker_binding` fallback. This is the migration gate for callers
1632/// that require every binding to use the platform-neutral Runner contract.
1633pub fn resolve_bound_agents_strict(
1634    bp: &Blueprint,
1635) -> Result<Vec<BoundAgent>, BoundAgentResolveError> {
1636    resolve_bound_agents_with_legacy(bp, false)
1637}
1638
1639fn resolve_bound_agents_with_legacy(
1640    bp: &Blueprint,
1641    allow_legacy: bool,
1642) -> Result<Vec<BoundAgent>, BoundAgentResolveError> {
1643    bp.agents
1644        .iter()
1645        .map(|agent| {
1646            let runner = resolve_runner(bp, agent)?;
1647            let runner_source = if agent.runner.is_some() {
1648                RunnerResolutionSource::AgentInline
1649            } else if agent.runner_ref.is_some() {
1650                RunnerResolutionSource::AgentRef
1651            } else if agent
1652                .profile
1653                .as_ref()
1654                .and_then(|p| p.worker_binding.as_ref())
1655                .is_some()
1656            {
1657                RunnerResolutionSource::LegacyWorkerBinding
1658            } else if bp.default_runner.is_some() {
1659                RunnerResolutionSource::BlueprintDefault
1660            } else {
1661                RunnerResolutionSource::None
1662            };
1663            if !allow_legacy && runner_source == RunnerResolutionSource::LegacyWorkerBinding {
1664                return Err(BoundAgentResolveError::LegacyWorkerBindingDisabled {
1665                    agent: agent.name.clone(),
1666                });
1667            }
1668            let context_policy = agent
1669                .meta
1670                .as_ref()
1671                .and_then(|m| m.context_policy.clone())
1672                .or_else(|| bp.default_context_policy.clone());
1673            let digest_input = BoundAgentDigestInput {
1674                agent,
1675                runner: &runner,
1676                context_policy: &context_policy,
1677                runner_source,
1678                attestation: &None,
1679            };
1680            let bytes = serde_json::to_vec(&digest_input).map_err(|source| {
1681                BoundAgentResolveError::Digest {
1682                    agent: agent.name.clone(),
1683                    source,
1684                }
1685            })?;
1686            let binding_digest = BindingDigest::sha256(bytes);
1687            Ok(BoundAgent {
1688                agent: agent.clone(),
1689                runner,
1690                context_policy,
1691                runner_source,
1692                attestation: None,
1693                binding_digest,
1694            })
1695        })
1696        .collect()
1697}
1698
1699// ──────────────────────────────────────────────────────────────────────────
1700// OperatorDef / OperatorKind
1701// ──────────────────────────────────────────────────────────────────────────
1702
1703/// Kind axis of an Operator role (= "in which mode does this Operator run").
1704/// Corresponds 1:1 with the engine's runtime `OperatorKind`. Kept as a schema
1705/// duplicate so that BPs can be authored while depending only on this crate.
1706#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default, JsonSchema)]
1707#[serde(rename_all = "snake_case")]
1708pub enum OperatorKind {
1709    /// MainAI (= interactive AI Operator via WS client or SDK).
1710    MainAi,
1711    /// Automate (= normal spawn path, without human interception).
1712    #[default]
1713    Automate,
1714    /// Composite (= MainAi + Automate running side by side).
1715    Composite,
1716}
1717
1718/// Design-time definition of an Operator role (first-class).
1719///
1720/// `AgentDef.spec.operator_ref` references this struct's `name` as a logical role name.
1721/// Binding to a runtime backend (WS session / SDK / pool, etc.) is established via the
1722/// attach path; the BP side only declares "under this logical name we expect an Operator
1723/// of this Kind".
1724///
1725/// `spec` is an escape hatch for kind-specific config (WS endpoint / SDK profile / pool
1726/// binding, etc.). Even when empty, declaring `name` + `kind` alone is enough for
1727/// compile-time validation to succeed (= it guarantees that agent `operator_ref` values
1728/// reference an existing definition).
1729#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, JsonSchema)]
1730#[serde(deny_unknown_fields)]
1731pub struct OperatorDef {
1732    /// Logical role name (= design-time symbol referenced from `AgentDef.spec.operator_ref`).
1733    pub name: String,
1734    /// Display name for UI / docs (optional).
1735    #[serde(default)]
1736    pub display_name: Option<String>,
1737    /// Kind axis of the Operator (MainAi / Automate / Composite) — the "BP
1738    /// Agent-level" tier of the 4-tier `OperatorKind` cascade (see
1739    /// `Blueprint.default_operator_kind` for the full tier list). `None`
1740    /// when this `OperatorDef` does not declare a kind; the resolver then
1741    /// falls through to BP Global / Default Fallback for agents referencing
1742    /// this role via `AgentDef.spec.operator_ref`.
1743    #[serde(default)]
1744    pub kind: Option<OperatorKind>,
1745    /// Kind-specific config (WS endpoint / SDK profile / pool binding, etc.). Interpreted
1746    /// by the factory.
1747    #[serde(default)]
1748    pub spec: Value,
1749    /// Operator persona information (e.g. system_prompt template). Same shape as
1750    /// `AgentDef.profile`. Used as a template when the Operator itself plays a "role".
1751    /// If `None`, the agent-side profile is used instead.
1752    #[serde(default)]
1753    pub profile: Option<AgentProfile>,
1754    /// Operator-level metadata (description / version / tags).
1755    #[serde(default)]
1756    pub meta: Option<AgentMeta>,
1757}
1758
1759/// Named, multi-step-shared declarative context payload (GH #21 Phase 2).
1760///
1761/// Lives in the [`Blueprint::metas`] pool and is referenced by name from
1762/// two independent consumers: a `$step_meta.ref` envelope embedded in a
1763/// Step's evaluated `in` value (the Step tier, resolved by
1764/// `EngineDispatcher::dispatch` in the `mlua-swarm` core crate at
1765/// dispatch time — see `EngineDispatcher::with_step_metas`), and
1766/// [`AgentMeta::meta_ref`] (the Agent tier, resolved at launch time and
1767/// merged UNDER the agent's inline `AgentMeta::ctx`). The pool lets
1768/// multiple Steps and/or Agents share one declarative context object by
1769/// name instead of repeating it inline.
1770#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, JsonSchema)]
1771#[serde(deny_unknown_fields)]
1772pub struct MetaDef {
1773    /// Logical name (= referenced by `$step_meta.ref` and
1774    /// `AgentMeta.meta_ref`; unique within [`Blueprint::metas`]).
1775    pub name: String,
1776    /// Declarative context payload. Consumers expect a JSON `Object` so
1777    /// it can be shallow-merged with an `inline` override / an agent's
1778    /// own `ctx` (a non-`Object` value is rejected — loudly at dispatch
1779    /// time for the Step tier, defensively (warn + skip) at launch time
1780    /// for the Agent tier); the shape is otherwise free-form.
1781    pub ctx: Value,
1782}
1783
1784/// GH #27 (follow-up to #23) — Blueprint-declared override of the
1785/// `mlua-swarm` core crate's placement resolver
1786/// (`mlua_swarm::core::projection_placement::ProjectionPlacement`), which
1787/// decides where a Step's materialized OUTPUT file (submit-time sink,
1788/// server read-back, and spawn-time `ctx_projection` pointer — the "3
1789/// path" convergence point) is written on disk. Both fields are
1790/// independently optional and validated (`dir_template`) at
1791/// `Compiler::compile` time — see that resolver's `from_spec` doc for the
1792/// full rejection rules.
1793#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, JsonSchema)]
1794#[serde(deny_unknown_fields)]
1795pub struct ProjectionPlacementSpec {
1796    /// Which of the spawn-time `work_dir` / `project_root` to prefer as
1797    /// the materialize root, falling back to the other when the
1798    /// preferred one is absent. `"work_dir"` (default, current
1799    /// byte-compat behavior) | `"project_root"`. `None` = the default
1800    /// (`"work_dir"`).
1801    #[serde(default, skip_serializing_if = "Option::is_none")]
1802    pub root: Option<String>,
1803    /// Target directory template, relative to the resolved root, with a
1804    /// `{task_id}` placeholder substituted at materialize time. `None` =
1805    /// the default (`"workspace/tasks/{task_id}/ctx"`, current byte-compat
1806    /// behavior). Must be non-empty, contain the `{task_id}` placeholder,
1807    /// stay relative, and not contain any `..` path segment — rejected at
1808    /// `Compiler::compile` time otherwise.
1809    #[serde(default, skip_serializing_if = "Option::is_none")]
1810    pub dir_template: Option<String>,
1811}
1812
1813/// Agent / Operator level metadata (description / version / tags).
1814#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default, JsonSchema)]
1815#[serde(deny_unknown_fields)]
1816pub struct AgentMeta {
1817    /// Short human-readable description.
1818    #[serde(default)]
1819    pub description: Option<String>,
1820    /// Free-form version label.
1821    #[serde(default)]
1822    pub version: Option<String>,
1823    /// Tag list for classification / routing.
1824    #[serde(default)]
1825    pub tags: Vec<String>,
1826    /// GH #21 Phase 1 — "BP Agent-level" tier of the agent-context supply
1827    /// axis: a declarative object merged into `ctx.meta.runtime` for this
1828    /// agent's spawns, on top of (and winning over)
1829    /// [`Blueprint::default_agent_ctx`]. See that field's doc for the
1830    /// contrast with `default_init_ctx`. `None` = this agent declares no
1831    /// per-agent context (the BP-global tier alone applies, if any).
1832    #[serde(default, skip_serializing_if = "Option::is_none")]
1833    #[schemars(with = "Option<Value>")]
1834    pub ctx: Option<Value>,
1835    /// GH #21 Phase 1 — "BP Agent-level" tier of the [`ContextPolicy`]
1836    /// cascade: outranks [`Blueprint::default_context_policy`] for this
1837    /// agent. `None` = fall through to the BP-global policy (or pass-all
1838    /// if that is also `None`).
1839    #[serde(default, skip_serializing_if = "Option::is_none")]
1840    pub context_policy: Option<ContextPolicy>,
1841    /// GH #21 Phase 2 — "BP Agent-level" tier of the [`MetaDef`] pool:
1842    /// resolves against [`Blueprint::metas`] by name. The resolved
1843    /// `ctx` sits UNDER this agent's inline [`Self::ctx`] (inline wins
1844    /// on key collision). `None` = this agent declares no shared
1845    /// `MetaDef` reference.
1846    #[serde(default, skip_serializing_if = "Option::is_none")]
1847    pub meta_ref: Option<String>,
1848    /// GH #23 — the step-projection canonical name this agent's dispatched
1849    /// Steps should be addressed by (data-plane submit / `ContextPolicy`
1850    /// filter / `StepPointer`/`StepSummary` `name` / REST `:step` path /
1851    /// materialized file stem — see `mlua-swarm` core's
1852    /// `core::step_naming::StepNaming` for the table this field feeds).
1853    /// `None` = this agent declares no projection name; the canonical
1854    /// name falls back to the Step's `ref` (the flow.ir data-plane
1855    /// producer name), matching pre-GH-#23 behavior byte-for-byte.
1856    #[serde(default, skip_serializing_if = "Option::is_none")]
1857    pub projection_name: Option<String>,
1858}
1859
1860// ──────────────────────────────────────────────────────────────────────────
1861// Compiler hints / strategy
1862// ──────────────────────────────────────────────────────────────────────────
1863
1864/// Per-agent overrides / hints. Interpreted by the Compiler / SpawnerFactory; not required.
1865#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default, JsonSchema)]
1866#[serde(deny_unknown_fields)]
1867pub struct CompilerHints {
1868    /// Agent name → per-agent hint (= passed to `SpawnerFactory.build`).
1869    #[serde(default)]
1870    pub per_agent: HashMap<String, Value>,
1871    /// Global hints (= e.g. parallel limit, default timeout, ...).
1872    #[serde(default)]
1873    pub global: Value,
1874}
1875
1876/// Compiler behavior rules. Controls strict / lenient handling and default fallback.
1877#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, JsonSchema)]
1878#[serde(deny_unknown_fields)]
1879pub struct CompilerStrategy {
1880    /// If `true` (default), an unresolved `Step.ref` is an error; if `false`, it falls
1881    /// through to the default Spawner.
1882    #[serde(default = "default_true")]
1883    pub strict_refs: bool,
1884    /// If `true` (default), an `AgentKind` missing from the registry is an error; if
1885    /// `false`, it is skipped.
1886    #[serde(default = "default_true")]
1887    pub strict_kind: bool,
1888    /// If `true`, every Runner-backed agent must obtain a Core-validated
1889    /// attestation at launch (a binding provider is required, and any agent
1890    /// the provider leaves `Unbound` fails the launch). If `false` (default),
1891    /// an unattested agent runs `DeclarationOnly` and the gap is only
1892    /// observed (tracing warn + a `RunRecord.degradations` entry).
1893    ///
1894    /// This default is deliberately the opposite of `strict_refs` /
1895    /// `strict_kind` (both default `true`): those two guard *structural
1896    /// integrity* of the Blueprint itself (an unresolved ref or unknown kind
1897    /// is always a Blueprint bug), whereas binding attestation is an
1898    /// *execution-assurance opt-in* — it depends on an execution environment
1899    /// being present to attest against, which is not available for embed-only
1900    /// or manifest-less launches. Requiring it by default would break every
1901    /// launch that has no provider, so it is opt-in per Blueprint.
1902    #[serde(default)]
1903    pub strict_binding: bool,
1904}
1905
1906fn default_true() -> bool {
1907    true
1908}
1909
1910impl Default for CompilerStrategy {
1911    fn default() -> Self {
1912        Self {
1913            strict_refs: true,
1914            strict_kind: true,
1915            strict_binding: false,
1916        }
1917    }
1918}
1919
1920// ──────────────────────────────────────────────────────────────────────────
1921// Blueprint metadata / origin
1922// ──────────────────────────────────────────────────────────────────────────
1923
1924/// Blueprint-level metadata (description / origin / tags / ttl / version label / alias).
1925#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default, JsonSchema)]
1926#[serde(deny_unknown_fields)]
1927pub struct BlueprintMetadata {
1928    /// Short human-readable description of the Blueprint.
1929    #[serde(default)]
1930    pub description: Option<String>,
1931    /// Provenance record (inline / file / algocline).
1932    #[serde(default)]
1933    pub origin: BlueprintOrigin,
1934    /// Tag list for classification / routing.
1935    #[serde(default)]
1936    pub tags: Vec<String>,
1937    /// Optional SemVer label (= match target for `TaskPipeline VersionSelector::SemVerReq`).
1938    /// Example: `"1.2.3"`. Rewritten by `EnhanceAdapter` on PATCH/MINOR/MAJOR bumps.
1939    #[serde(default, skip_serializing_if = "Option::is_none")]
1940    pub version_label: Option<String>,
1941    /// Optional LDS session alias label. The Swarm engine itself does not apply this
1942    /// (= it is free-form content); the value is expanded into the Spawn directive and
1943    /// reaches the MainAI. The MainAI is expected to establish a task session via
1944    /// `mcp__lds__session_create(root=..., alias=<this>)`, and to inject
1945    /// `LDS Session Alias: <this>` verbatim into the SubAgent dispatch prompt body.
1946    /// The SubAgent body then calls `mcp__lds__session_start(alias=<this>)` with the
1947    /// received alias. Worktree ownership is thereby unified under a single session, and
1948    /// cross-SubAgent / cross-worktree ownership blocks (= `not owned by this session`)
1949    /// cannot fire structurally.
1950    #[serde(default, skip_serializing_if = "Option::is_none")]
1951    pub project_name_alias: Option<String>,
1952    /// Optional default TTL (seconds) for tasks dispatched via this BP. Estimated by the
1953    /// Blueprint author from the flow shape (agent count × expected duration per agent).
1954    /// If `POST /v1/tasks` supplies `ttl_secs` explicitly, the body value wins; otherwise
1955    /// this metadata field is used as the default; if both are absent, the server global
1956    /// default (`default_run_ttl()` = 1800s) applies. Not needed for short chains (~5 min);
1957    /// recommended for long chains (14 agents × several minutes = 30-60 min).
1958    #[serde(default, skip_serializing_if = "Option::is_none")]
1959    pub default_run_ttl_secs: Option<u64>,
1960    /// GH #50 follow-up (issue `33bc825b`): promote `VerdictValueUnhandled`
1961    /// compile-time lint to a hard error. When `false` (or absent), a
1962    /// declared `AgentDef.verdict.values` entry that no downstream cond
1963    /// references is only surfaced via `tracing::warn!` (informational);
1964    /// when `true`, `Compiler::compile` rejects the Blueprint with
1965    /// `CompileError::VerdictValueUnhandled`. Opt-in so existing Blueprints
1966    /// that intentionally leave some verdict values as silent-pass
1967    /// informational tokens keep compiling unchanged.
1968    #[serde(default, skip_serializing_if = "Option::is_none")]
1969    pub strict_verdict_handling: Option<bool>,
1970}
1971
1972/// Provenance record of a Blueprint.
1973#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default, JsonSchema)]
1974#[serde(tag = "kind", rename_all = "snake_case")]
1975pub enum BlueprintOrigin {
1976    /// Inline construction, e.g. via a Rust struct literal or test code.
1977    #[default]
1978    Inline,
1979    /// Loaded from a file.
1980    File {
1981        /// Source file path.
1982        path: String,
1983    },
1984    /// Emitted by an algocline strategy (traced by `session_id`).
1985    Algo {
1986        /// Algocline session identifier.
1987        session_id: String,
1988    },
1989}
1990
1991#[cfg(test)]
1992mod tests {
1993    use super::*;
1994
1995    #[test]
1996    fn schema_version_default_parses() {
1997        let v = default_schema_version();
1998        assert_eq!(v.to_string(), "0.1.0");
1999    }
2000
2001    #[test]
2002    fn current_schema_version_const_matches() {
2003        assert_eq!(CURRENT_SCHEMA_VERSION, "0.1.0");
2004    }
2005
2006    #[test]
2007    fn blueprint_json_schema_exports_key_properties() {
2008        let schema = schemars::schema_for!(Blueprint);
2009        let v = serde_json::to_value(&schema).expect("schema serializes");
2010        let props = v["properties"].as_object().expect("object schema");
2011        for key in [
2012            "schema_version",
2013            "id",
2014            "flow",
2015            "agents",
2016            "operators",
2017            "metas",
2018            "hints",
2019            "strategy",
2020            "metadata",
2021            "spawner_hints",
2022            "default_agent_kind",
2023            "default_operator_kind",
2024            "default_init_ctx",
2025            "default_agent_ctx",
2026            "default_context_policy",
2027            "projection_placement",
2028            "audits",
2029            "runners",
2030            "default_runner",
2031            "check_policy",
2032        ] {
2033            assert!(props.contains_key(key), "missing property: {key}");
2034        }
2035        // semver override lands as a plain string
2036        assert_eq!(v["properties"]["schema_version"]["type"], "string");
2037        // enum variants (snake_case) survive into the schema (LLM author axis)
2038        let dump = v.to_string();
2039        assert!(dump.contains("agent_block"), "AgentKind variants in schema");
2040        assert!(dump.contains("main_ai"), "OperatorKind variants in schema");
2041        // nested defs are referenced (AgentDef reachable from agents[])
2042        assert!(dump.contains("AgentDef"), "AgentDef definition in schema");
2043    }
2044
2045    #[test]
2046    fn agent_profile_worker_binding_roundtrips_when_some() {
2047        let profile = AgentProfile {
2048            worker_binding: Some("code-worker".to_string()),
2049            ..Default::default()
2050        };
2051        let json = serde_json::to_value(&profile).expect("serializes");
2052        assert_eq!(json["worker_binding"], "code-worker");
2053        let back: AgentProfile = serde_json::from_value(json).expect("deserializes");
2054        assert_eq!(back.worker_binding.as_deref(), Some("code-worker"));
2055    }
2056
2057    #[test]
2058    fn agent_profile_worker_binding_omitted_when_none() {
2059        let profile = AgentProfile::default();
2060        let json = serde_json::to_value(&profile).expect("serializes");
2061        // `skip_serializing_if = "Option::is_none"` — the key must not appear at all.
2062        assert!(
2063            json.as_object().unwrap().get("worker_binding").is_none(),
2064            "worker_binding key must be absent when None: {json}"
2065        );
2066        let back: AgentProfile = serde_json::from_value(json).expect("deserializes");
2067        assert_eq!(back.worker_binding, None);
2068    }
2069
2070    // ──────────────────────────────────────────────────────────────
2071    // issue #19 ST3: `Blueprint.default_init_ctx`
2072    // ──────────────────────────────────────────────────────────────
2073
2074    fn minimal_bp(default_init_ctx: Option<Value>) -> Blueprint {
2075        Blueprint {
2076            schema_version: current_schema_version(),
2077            id: "bp-init-ctx-ut".into(),
2078            flow: FlowNode::Seq { children: vec![] },
2079            agents: vec![],
2080            operators: vec![],
2081            metas: vec![],
2082            hints: Default::default(),
2083            strategy: Default::default(),
2084            metadata: Default::default(),
2085            spawner_hints: Default::default(),
2086            default_agent_kind: AgentKind::Operator,
2087            default_operator_kind: None,
2088            default_init_ctx,
2089            default_agent_ctx: None,
2090            default_context_policy: None,
2091            projection_placement: None,
2092            audits: vec![],
2093            degradation_policy: None,
2094            runners: vec![],
2095            default_runner: None,
2096            subprocesses: vec![],
2097            check_policy: None,
2098            blueprint_ref_includes: Vec::new(),
2099        }
2100    }
2101
2102    #[test]
2103    fn blueprint_default_init_ctx_roundtrips_when_some() {
2104        let bp = minimal_bp(Some(serde_json::json!({ "seeded": true })));
2105        let json = serde_json::to_string(&bp).expect("serializes");
2106        let back: Blueprint = serde_json::from_str(&json).expect("deserializes");
2107        assert_eq!(
2108            back.default_init_ctx,
2109            Some(serde_json::json!({ "seeded": true }))
2110        );
2111        assert_eq!(bp, back);
2112    }
2113
2114    #[test]
2115    fn blueprint_default_init_ctx_omitted_when_none() {
2116        let bp = minimal_bp(None);
2117        let json = serde_json::to_value(&bp).expect("serializes");
2118        // `skip_serializing_if = "Option::is_none"` — the key must not appear at all
2119        // (pre-#19 Blueprints round-trip byte-identical through this path).
2120        assert!(
2121            json.as_object().unwrap().get("default_init_ctx").is_none(),
2122            "default_init_ctx key must be absent when None: {json}"
2123        );
2124        let back: Blueprint = serde_json::from_value(json).expect("deserializes");
2125        assert_eq!(back.default_init_ctx, None);
2126        assert_eq!(bp, back);
2127    }
2128
2129    #[test]
2130    fn blueprint_json_schema_exports_default_init_ctx_as_nullable_value() {
2131        let schema = schemars::schema_for!(Blueprint);
2132        let v = serde_json::to_value(&schema).expect("schema serializes");
2133        assert!(
2134            v["properties"]["default_init_ctx"].is_object(),
2135            "default_init_ctx must appear in the exported schema: {v}"
2136        );
2137    }
2138
2139    // ──────────────────────────────────────────────────────────────
2140    // issue #21 Phase 1: `Blueprint.default_agent_ctx` /
2141    // `default_context_policy`, `AgentMeta.ctx` / `context_policy`,
2142    // `ContextPolicy`
2143    // ──────────────────────────────────────────────────────────────
2144
2145    #[test]
2146    fn blueprint_default_agent_ctx_and_context_policy_roundtrip_when_some() {
2147        let mut bp = minimal_bp(None);
2148        bp.default_agent_ctx = Some(serde_json::json!({ "org_conventions": "x" }));
2149        bp.default_context_policy = Some(ContextPolicy {
2150            include: Some(vec!["project_root".to_string()]),
2151            exclude: vec!["work_dir".to_string()],
2152            ..Default::default()
2153        });
2154        let json = serde_json::to_string(&bp).expect("serializes");
2155        let back: Blueprint = serde_json::from_str(&json).expect("deserializes");
2156        assert_eq!(bp, back);
2157        assert_eq!(
2158            back.default_agent_ctx,
2159            Some(serde_json::json!({ "org_conventions": "x" }))
2160        );
2161        assert_eq!(
2162            back.default_context_policy,
2163            Some(ContextPolicy {
2164                include: Some(vec!["project_root".to_string()]),
2165                exclude: vec!["work_dir".to_string()],
2166                ..Default::default()
2167            })
2168        );
2169    }
2170
2171    #[test]
2172    fn blueprint_default_agent_ctx_and_context_policy_omitted_when_none() {
2173        let bp = minimal_bp(None);
2174        let json = serde_json::to_value(&bp).expect("serializes");
2175        let obj = json.as_object().unwrap();
2176        assert!(
2177            obj.get("default_agent_ctx").is_none(),
2178            "default_agent_ctx key must be absent when None: {json}"
2179        );
2180        assert!(
2181            obj.get("default_context_policy").is_none(),
2182            "default_context_policy key must be absent when None: {json}"
2183        );
2184        let back: Blueprint = serde_json::from_value(json).expect("deserializes");
2185        assert_eq!(back.default_agent_ctx, None);
2186        assert_eq!(back.default_context_policy, None);
2187        assert_eq!(bp, back);
2188    }
2189
2190    #[test]
2191    fn blueprint_json_schema_exports_agent_ctx_and_context_policy() {
2192        let schema = schemars::schema_for!(Blueprint);
2193        let v = serde_json::to_value(&schema).expect("schema serializes");
2194        assert!(
2195            v["properties"]["default_agent_ctx"].is_object(),
2196            "default_agent_ctx must appear in the exported schema: {v}"
2197        );
2198        assert!(
2199            v["properties"]["default_context_policy"].is_object(),
2200            "default_context_policy must appear in the exported schema: {v}"
2201        );
2202    }
2203
2204    // ──────────────────────────────────────────────────────────────
2205    // GH #27 (follow-up to #23): `Blueprint.projection_placement` /
2206    // `ProjectionPlacementSpec`
2207    // ──────────────────────────────────────────────────────────────
2208
2209    #[test]
2210    fn blueprint_projection_placement_roundtrips_when_some() {
2211        let mut bp = minimal_bp(None);
2212        bp.projection_placement = Some(ProjectionPlacementSpec {
2213            root: Some("project_root".to_string()),
2214            dir_template: Some("custom/{task_id}/out".to_string()),
2215        });
2216        let json = serde_json::to_string(&bp).expect("serializes");
2217        let back: Blueprint = serde_json::from_str(&json).expect("deserializes");
2218        assert_eq!(bp, back);
2219        assert_eq!(
2220            back.projection_placement,
2221            Some(ProjectionPlacementSpec {
2222                root: Some("project_root".to_string()),
2223                dir_template: Some("custom/{task_id}/out".to_string()),
2224            })
2225        );
2226    }
2227
2228    #[test]
2229    fn blueprint_projection_placement_omitted_when_none() {
2230        let bp = minimal_bp(None);
2231        let json = serde_json::to_value(&bp).expect("serializes");
2232        assert!(
2233            json.as_object()
2234                .unwrap()
2235                .get("projection_placement")
2236                .is_none(),
2237            "projection_placement key must be absent when None: {json}"
2238        );
2239        let back: Blueprint = serde_json::from_value(json).expect("deserializes");
2240        assert_eq!(back.projection_placement, None);
2241        assert_eq!(bp, back);
2242    }
2243
2244    #[test]
2245    fn blueprint_json_schema_exports_projection_placement() {
2246        let schema = schemars::schema_for!(Blueprint);
2247        let v = serde_json::to_value(&schema).expect("schema serializes");
2248        assert!(
2249            v["properties"]["projection_placement"].is_object(),
2250            "projection_placement must appear in the exported schema: {v}"
2251        );
2252    }
2253
2254    #[test]
2255    fn agent_meta_ctx_and_context_policy_roundtrip_when_some() {
2256        let meta = AgentMeta {
2257            ctx: Some(serde_json::json!({ "k": "v" })),
2258            context_policy: Some(ContextPolicy {
2259                include: None,
2260                exclude: vec!["run_id".to_string()],
2261                ..Default::default()
2262            }),
2263            ..Default::default()
2264        };
2265        let json = serde_json::to_value(&meta).expect("serializes");
2266        let back: AgentMeta = serde_json::from_value(json).expect("deserializes");
2267        assert_eq!(back, meta);
2268    }
2269
2270    #[test]
2271    fn agent_meta_ctx_and_context_policy_omitted_when_none() {
2272        let meta = AgentMeta::default();
2273        let json = serde_json::to_value(&meta).expect("serializes");
2274        let obj = json.as_object().unwrap();
2275        assert!(
2276            obj.get("ctx").is_none(),
2277            "ctx key must be absent when None: {json}"
2278        );
2279        assert!(
2280            obj.get("context_policy").is_none(),
2281            "context_policy key must be absent when None: {json}"
2282        );
2283    }
2284
2285    #[test]
2286    fn agent_meta_json_schema_exports_ctx_context_policy_and_meta_ref() {
2287        let schema = schemars::schema_for!(AgentMeta);
2288        let v = serde_json::to_value(&schema).expect("schema serializes");
2289        let props = v["properties"].as_object().expect("object schema");
2290        for key in [
2291            "description",
2292            "version",
2293            "tags",
2294            "ctx",
2295            "context_policy",
2296            "meta_ref",
2297            "projection_name",
2298        ] {
2299            assert!(props.contains_key(key), "missing property: {key}");
2300        }
2301    }
2302
2303    // ──────────────────────────────────────────────────────────────
2304    // issue #21 Phase 2: `MetaDef`, `Blueprint.metas`, `AgentMeta.meta_ref`
2305    // ──────────────────────────────────────────────────────────────
2306
2307    #[test]
2308    fn meta_def_roundtrips_through_json() {
2309        let def = MetaDef {
2310            name: "heavy-scan".to_string(),
2311            ctx: serde_json::json!({ "work_dir": "/x" }),
2312        };
2313        let json = serde_json::to_value(&def).expect("serializes");
2314        let back: MetaDef = serde_json::from_value(json).expect("deserializes");
2315        assert_eq!(back, def);
2316    }
2317
2318    #[test]
2319    fn blueprint_metas_omitted_when_empty() {
2320        let bp = minimal_bp(None);
2321        let json = serde_json::to_value(&bp).expect("serializes");
2322        assert!(
2323            json.as_object().unwrap().get("metas").is_none(),
2324            "metas key must be absent when empty: {json}"
2325        );
2326        let back: Blueprint = serde_json::from_value(json).expect("deserializes");
2327        assert!(back.metas.is_empty());
2328        assert_eq!(bp, back);
2329    }
2330
2331    #[test]
2332    fn blueprint_metas_roundtrips_when_non_empty() {
2333        let mut bp = minimal_bp(None);
2334        bp.metas = vec![MetaDef {
2335            name: "heavy-scan".to_string(),
2336            ctx: serde_json::json!({ "work_dir": "/x" }),
2337        }];
2338        let json = serde_json::to_string(&bp).expect("serializes");
2339        let back: Blueprint = serde_json::from_str(&json).expect("deserializes");
2340        assert_eq!(bp, back);
2341        assert_eq!(back.metas.len(), 1);
2342        assert_eq!(back.metas[0].name, "heavy-scan");
2343    }
2344
2345    #[test]
2346    fn blueprint_json_schema_exports_metas() {
2347        let schema = schemars::schema_for!(Blueprint);
2348        let v = serde_json::to_value(&schema).expect("schema serializes");
2349        assert!(
2350            v["properties"]["metas"].is_object(),
2351            "metas must appear in the exported schema: {v}"
2352        );
2353        let dump = v.to_string();
2354        assert!(dump.contains("MetaDef"), "MetaDef definition in schema");
2355    }
2356
2357    #[test]
2358    fn agent_meta_meta_ref_roundtrips_when_some() {
2359        let meta = AgentMeta {
2360            meta_ref: Some("heavy-scan".to_string()),
2361            ..Default::default()
2362        };
2363        let json = serde_json::to_value(&meta).expect("serializes");
2364        assert_eq!(json["meta_ref"], "heavy-scan");
2365        let back: AgentMeta = serde_json::from_value(json).expect("deserializes");
2366        assert_eq!(back, meta);
2367    }
2368
2369    #[test]
2370    fn agent_meta_meta_ref_omitted_when_none() {
2371        let meta = AgentMeta::default();
2372        let json = serde_json::to_value(&meta).expect("serializes");
2373        assert!(
2374            json.as_object().unwrap().get("meta_ref").is_none(),
2375            "meta_ref key must be absent when None: {json}"
2376        );
2377        let back: AgentMeta = serde_json::from_value(json).expect("deserializes");
2378        assert_eq!(back.meta_ref, None);
2379    }
2380
2381    // ──────────────────────────────────────────────────────────────
2382    // GH #23: `AgentMeta.projection_name`
2383    // ──────────────────────────────────────────────────────────────
2384
2385    #[test]
2386    fn agent_meta_projection_name_roundtrips_when_some() {
2387        let meta = AgentMeta {
2388            projection_name: Some("plan".to_string()),
2389            ..Default::default()
2390        };
2391        let json = serde_json::to_value(&meta).expect("serializes");
2392        assert_eq!(json["projection_name"], "plan");
2393        let back: AgentMeta = serde_json::from_value(json).expect("deserializes");
2394        assert_eq!(back, meta);
2395    }
2396
2397    #[test]
2398    fn agent_meta_projection_name_omitted_when_none() {
2399        let meta = AgentMeta::default();
2400        let json = serde_json::to_value(&meta).expect("serializes");
2401        assert!(
2402            json.as_object().unwrap().get("projection_name").is_none(),
2403            "projection_name key must be absent when None: {json}"
2404        );
2405        let back: AgentMeta = serde_json::from_value(json).expect("deserializes");
2406        assert_eq!(back.projection_name, None);
2407        assert_eq!(back, meta);
2408    }
2409
2410    #[test]
2411    fn agent_meta_rejects_unknown_field_with_projection_name_present() {
2412        // `deny_unknown_fields` must still reject an unrelated stray key
2413        // even when `projection_name` is present alongside it (regression
2414        // guard: adding the field must not accidentally loosen the
2415        // contract for the rest of the struct).
2416        let json = serde_json::json!({
2417            "projection_name": "plan",
2418            "not_a_real_field": true
2419        });
2420        let err = serde_json::from_value::<AgentMeta>(json).unwrap_err();
2421        assert!(
2422            err.to_string().contains("not_a_real_field")
2423                || err.to_string().contains("unknown field"),
2424            "expected an unknown-field rejection, got: {err}"
2425        );
2426    }
2427
2428    #[test]
2429    fn context_policy_default_allows_everything() {
2430        let policy = ContextPolicy::default();
2431        assert!(policy.allows("project_root"));
2432        assert!(policy.allows("anything"));
2433    }
2434
2435    #[test]
2436    fn context_policy_include_only_allows_listed_names() {
2437        let policy = ContextPolicy {
2438            include: Some(vec!["project_root".to_string()]),
2439            exclude: vec![],
2440            ..Default::default()
2441        };
2442        assert!(policy.allows("project_root"));
2443        assert!(!policy.allows("work_dir"));
2444    }
2445
2446    #[test]
2447    fn context_policy_exclude_wins_over_include() {
2448        let policy = ContextPolicy {
2449            include: Some(vec!["project_root".to_string()]),
2450            exclude: vec!["project_root".to_string()],
2451            ..Default::default()
2452        };
2453        assert!(!policy.allows("project_root"));
2454    }
2455
2456    #[test]
2457    fn context_policy_roundtrips_through_json() {
2458        let policy = ContextPolicy {
2459            include: Some(vec!["a".to_string(), "b".to_string()]),
2460            exclude: vec!["c".to_string()],
2461            ..Default::default()
2462        };
2463        let json = serde_json::to_value(&policy).expect("serializes");
2464        let back: ContextPolicy = serde_json::from_value(json).expect("deserializes");
2465        assert_eq!(back, policy);
2466    }
2467
2468    #[test]
2469    fn context_policy_default_roundtrips_as_empty_object() {
2470        let policy = ContextPolicy::default();
2471        let json = serde_json::to_value(&policy).expect("serializes");
2472        assert_eq!(
2473            json,
2474            serde_json::json!({
2475                "include": null,
2476                "exclude": [],
2477                "steps": null,
2478                "steps_exclude": [],
2479            })
2480        );
2481        let back: ContextPolicy = serde_json::from_value(json).expect("deserializes");
2482        assert_eq!(back, policy);
2483    }
2484
2485    // ──────────────────────────────────────────────────────────────
2486    // ST5 (`projection-adapter`): `ContextPolicy.steps` / `steps_exclude`
2487    // ──────────────────────────────────────────────────────────────
2488
2489    #[test]
2490    fn context_policy_steps_default_allows_every_step() {
2491        let policy = ContextPolicy::default();
2492        assert!(policy.allows_step("planner"));
2493        assert!(policy.allows_step("anything"));
2494    }
2495
2496    #[test]
2497    fn context_policy_steps_include_only_allows_listed_names() {
2498        let policy = ContextPolicy {
2499            steps: Some(vec!["planner".to_string()]),
2500            ..Default::default()
2501        };
2502        assert!(policy.allows_step("planner"));
2503        assert!(!policy.allows_step("coder"));
2504    }
2505
2506    #[test]
2507    fn context_policy_steps_empty_list_allows_none() {
2508        let policy = ContextPolicy {
2509            steps: Some(vec![]),
2510            ..Default::default()
2511        };
2512        assert!(!policy.allows_step("planner"));
2513    }
2514
2515    #[test]
2516    fn context_policy_steps_exclude_wins_over_steps() {
2517        let policy = ContextPolicy {
2518            steps: Some(vec!["planner".to_string()]),
2519            steps_exclude: vec!["planner".to_string()],
2520            ..Default::default()
2521        };
2522        assert!(!policy.allows_step("planner"));
2523    }
2524
2525    #[test]
2526    fn context_policy_steps_roundtrips_through_json() {
2527        let policy = ContextPolicy {
2528            steps: Some(vec!["planner".to_string(), "coder".to_string()]),
2529            steps_exclude: vec!["reviewer".to_string()],
2530            ..Default::default()
2531        };
2532        let json = serde_json::to_value(&policy).expect("serializes");
2533        let back: ContextPolicy = serde_json::from_value(json).expect("deserializes");
2534        assert_eq!(back, policy);
2535    }
2536
2537    // ──────────────────────────────────────────────────────────────
2538    // GH #34: `AuditDef`, `AuditMode`, `Blueprint.audits`
2539    // ──────────────────────────────────────────────────────────────
2540
2541    #[test]
2542    fn blueprint_audits_omitted_when_empty() {
2543        let bp = minimal_bp(None);
2544        let json = serde_json::to_value(&bp).expect("serializes");
2545        assert!(
2546            json.as_object().unwrap().get("audits").is_none(),
2547            "audits key must be absent when empty: {json}"
2548        );
2549        let back: Blueprint = serde_json::from_value(json).expect("deserializes");
2550        assert!(back.audits.is_empty());
2551        assert_eq!(bp, back);
2552    }
2553
2554    #[test]
2555    fn blueprint_audits_roundtrips_when_non_empty() {
2556        let mut bp = minimal_bp(None);
2557        bp.audits = vec![AuditDef {
2558            agent: "auditor".to_string(),
2559            steps: Some(vec!["worker".to_string()]),
2560            mode: AuditMode::Sync,
2561        }];
2562        let json = serde_json::to_string(&bp).expect("serializes");
2563        let back: Blueprint = serde_json::from_str(&json).expect("deserializes");
2564        assert_eq!(bp, back);
2565        assert_eq!(back.audits.len(), 1);
2566        assert_eq!(back.audits[0].agent, "auditor");
2567        assert_eq!(back.audits[0].mode, AuditMode::Sync);
2568    }
2569
2570    #[test]
2571    fn audit_def_steps_none_and_mode_default_when_omitted() {
2572        let json = serde_json::json!({ "agent": "auditor" });
2573        let def: AuditDef = serde_json::from_value(json).expect("deserializes");
2574        assert_eq!(def.steps, None);
2575        assert_eq!(def.mode, AuditMode::Async);
2576    }
2577
2578    #[test]
2579    fn audit_def_rejects_unknown_field() {
2580        let json = serde_json::json!({ "agent": "auditor", "not_a_real_field": true });
2581        let err = serde_json::from_value::<AuditDef>(json).unwrap_err();
2582        assert!(
2583            err.to_string().contains("not_a_real_field")
2584                || err.to_string().contains("unknown field"),
2585            "expected an unknown-field rejection, got: {err}"
2586        );
2587    }
2588
2589    #[test]
2590    fn audit_mode_serializes_snake_case() {
2591        assert_eq!(
2592            serde_json::to_value(AuditMode::Async).unwrap(),
2593            serde_json::json!("async")
2594        );
2595        assert_eq!(
2596            serde_json::to_value(AuditMode::Sync).unwrap(),
2597            serde_json::json!("sync")
2598        );
2599    }
2600
2601    #[test]
2602    fn blueprint_json_schema_exports_audits_and_audit_def() {
2603        let schema = schemars::schema_for!(Blueprint);
2604        let v = serde_json::to_value(&schema).expect("schema serializes");
2605        assert!(
2606            v["properties"]["audits"].is_object(),
2607            "audits must appear in the exported schema: {v}"
2608        );
2609        let dump = v.to_string();
2610        assert!(dump.contains("AuditDef"), "AuditDef definition in schema");
2611    }
2612
2613    // ──────────────────────────────────────────────────────────────
2614    // GH #32: `Blueprint.degradation_policy`, `DegradationPolicy`
2615    // ──────────────────────────────────────────────────────────────
2616
2617    #[test]
2618    fn blueprint_without_degradation_policy_deserializes_to_none() {
2619        let json = serde_json::json!({
2620            "schema_version": current_schema_version(),
2621            "id": "no-degradation-policy-ut",
2622            "flow": { "kind": "seq", "children": [] },
2623        });
2624        let bp: Blueprint = serde_json::from_value(json).expect("deserializes");
2625        assert_eq!(bp.degradation_policy, None);
2626    }
2627
2628    #[test]
2629    fn blueprint_degradation_policy_omitted_when_none() {
2630        let bp = minimal_bp(None);
2631        let json = serde_json::to_value(&bp).expect("serializes");
2632        assert!(
2633            json.as_object()
2634                .unwrap()
2635                .get("degradation_policy")
2636                .is_none(),
2637            "degradation_policy key must be absent when None: {json}"
2638        );
2639    }
2640
2641    #[test]
2642    fn blueprint_degradation_policy_warn_and_fail_roundtrip() {
2643        for (label, expected) in [
2644            ("warn", DegradationPolicy::Warn),
2645            ("fail", DegradationPolicy::Fail),
2646        ] {
2647            let mut bp = minimal_bp(None);
2648            bp.degradation_policy = Some(expected);
2649            let json = serde_json::to_string(&bp).expect("serializes");
2650            assert!(json.contains(&format!("\"degradation_policy\":\"{label}\"")));
2651            let back: Blueprint = serde_json::from_str(&json).expect("deserializes");
2652            assert_eq!(back.degradation_policy, Some(expected));
2653        }
2654    }
2655
2656    #[test]
2657    fn degradation_policy_rejects_unknown_variant() {
2658        let json = serde_json::json!({
2659            "schema_version": current_schema_version(),
2660            "id": "degradation-policy-unknown-variant-ut",
2661            "flow": { "kind": "seq", "children": [] },
2662            "degradation_policy": "ignore",
2663        });
2664        let err = serde_json::from_value::<Blueprint>(json).unwrap_err();
2665        assert!(
2666            err.to_string().contains("unknown variant"),
2667            "expected an unknown-variant rejection, got: {err}"
2668        );
2669    }
2670
2671    // ──────────────────────────────────────────────────────────────
2672    // GH #46 Milestone 2: `Runner`, `RunnerDef`, `WorkerModel`,
2673    // `Blueprint.runners` / `default_runner`, `AgentDef.runner` /
2674    // `runner_ref`, `resolve_runner`
2675    // ──────────────────────────────────────────────────────────────
2676
2677    fn agent_with_runner(
2678        name: &str,
2679        profile: Option<AgentProfile>,
2680        runner: Option<Runner>,
2681        runner_ref: Option<String>,
2682    ) -> AgentDef {
2683        AgentDef {
2684            name: name.to_string(),
2685            kind: AgentKind::RustFn,
2686            spec: serde_json::json!({ "fn_id": name }),
2687            profile,
2688            meta: None,
2689            runner,
2690            runner_ref,
2691            verdict: None,
2692        }
2693    }
2694
2695    fn ws_runner(variant: &str, tools: Vec<&str>) -> Runner {
2696        Runner::WsClaudeCode {
2697            variant: variant.to_string(),
2698            tools: tools.into_iter().map(str::to_string).collect(),
2699        }
2700    }
2701
2702    fn agent_block_runner(tools: Vec<&str>) -> Runner {
2703        Runner::AgentBlockInProcess {
2704            tools: tools.into_iter().map(str::to_string).collect(),
2705        }
2706    }
2707
2708    // ─── round-trip byte-compat ─────────────────────────────────────
2709
2710    #[test]
2711    fn blueprint_without_runners_or_default_runner_deserializes_to_defaults() {
2712        let json = serde_json::json!({
2713            "schema_version": current_schema_version(),
2714            "id": "no-runners-ut",
2715            "flow": { "kind": "seq", "children": [] },
2716        });
2717        let bp: Blueprint = serde_json::from_value(json).expect("deserializes");
2718        assert!(bp.runners.is_empty());
2719        assert_eq!(bp.default_runner, None);
2720    }
2721
2722    #[test]
2723    fn blueprint_runners_omitted_when_empty() {
2724        let bp = minimal_bp(None);
2725        let json = serde_json::to_value(&bp).expect("serializes");
2726        assert!(
2727            json.as_object().unwrap().get("runners").is_none(),
2728            "runners key must be absent when empty: {json}"
2729        );
2730        let back: Blueprint = serde_json::from_value(json).expect("deserializes");
2731        assert!(back.runners.is_empty());
2732        assert_eq!(bp, back);
2733    }
2734
2735    #[test]
2736    fn blueprint_runners_roundtrips_when_non_empty() {
2737        let mut bp = minimal_bp(None);
2738        bp.runners = vec![RunnerDef {
2739            name: "claude-worker".to_string(),
2740            runner: ws_runner("code-worker", vec!["Read", "Grep"]),
2741        }];
2742        let json = serde_json::to_string(&bp).expect("serializes");
2743        let back: Blueprint = serde_json::from_str(&json).expect("deserializes");
2744        assert_eq!(bp, back);
2745        assert_eq!(back.runners.len(), 1);
2746        assert_eq!(back.runners[0].name, "claude-worker");
2747    }
2748
2749    #[test]
2750    fn blueprint_default_runner_roundtrips_when_some() {
2751        let mut bp = minimal_bp(None);
2752        bp.default_runner = Some("claude-worker".to_string());
2753        let json = serde_json::to_value(&bp).expect("serializes");
2754        assert_eq!(json["default_runner"], "claude-worker");
2755        let back: Blueprint = serde_json::from_value(json).expect("deserializes");
2756        assert_eq!(back, bp);
2757    }
2758
2759    #[test]
2760    fn blueprint_default_runner_omitted_when_none() {
2761        let bp = minimal_bp(None);
2762        let json = serde_json::to_value(&bp).expect("serializes");
2763        assert!(
2764            json.as_object().unwrap().get("default_runner").is_none(),
2765            "default_runner key must be absent when None: {json}"
2766        );
2767        let back: Blueprint = serde_json::from_value(json).expect("deserializes");
2768        assert_eq!(back, bp);
2769    }
2770
2771    #[test]
2772    fn blueprint_json_schema_exports_runners_and_default_runner() {
2773        let schema = schemars::schema_for!(Blueprint);
2774        let v = serde_json::to_value(&schema).expect("schema serializes");
2775        assert!(
2776            v["properties"]["runners"].is_object(),
2777            "runners must appear in the exported schema: {v}"
2778        );
2779        assert!(
2780            v["properties"]["default_runner"].is_object(),
2781            "default_runner must appear in the exported schema: {v}"
2782        );
2783        let dump = v.to_string();
2784        assert!(dump.contains("RunnerDef"), "RunnerDef definition in schema");
2785        assert!(dump.contains("Runner"), "Runner definition in schema");
2786    }
2787
2788    #[test]
2789    fn agent_def_runner_and_runner_ref_omitted_when_none() {
2790        let agent = agent_with_runner("scout", None, None, None);
2791        let json = serde_json::to_value(&agent).expect("serializes");
2792        let obj = json.as_object().unwrap();
2793        assert!(
2794            obj.get("runner").is_none(),
2795            "runner key must be absent when None: {json}"
2796        );
2797        assert!(
2798            obj.get("runner_ref").is_none(),
2799            "runner_ref key must be absent when None: {json}"
2800        );
2801        let back: AgentDef = serde_json::from_value(json).expect("deserializes");
2802        assert_eq!(back, agent);
2803    }
2804
2805    #[test]
2806    fn agent_def_runner_inline_roundtrips_when_some() {
2807        let agent = agent_with_runner("coder", None, Some(agent_block_runner(vec!["Bash"])), None);
2808        let json = serde_json::to_string(&agent).expect("serializes");
2809        let back: AgentDef = serde_json::from_str(&json).expect("deserializes");
2810        assert_eq!(back, agent);
2811    }
2812
2813    #[test]
2814    fn agent_def_runner_ref_roundtrips_when_some() {
2815        let agent = agent_with_runner("coder", None, None, Some("claude-worker".to_string()));
2816        let json = serde_json::to_value(&agent).expect("serializes");
2817        assert_eq!(json["runner_ref"], "claude-worker");
2818        let back: AgentDef = serde_json::from_value(json).expect("deserializes");
2819        assert_eq!(back, agent);
2820    }
2821
2822    #[test]
2823    fn agent_def_json_schema_exports_runner_and_runner_ref() {
2824        let schema = schemars::schema_for!(AgentDef);
2825        let v = serde_json::to_value(&schema).expect("schema serializes");
2826        let props = v["properties"].as_object().expect("object schema");
2827        for key in ["runner", "runner_ref"] {
2828            assert!(props.contains_key(key), "missing property: {key}");
2829        }
2830    }
2831
2832    #[test]
2833    fn runner_ws_claude_code_roundtrips_through_json_and_tags_backend() {
2834        let runner = ws_runner("code-worker", vec!["Read", "Grep"]);
2835        let json = serde_json::to_value(&runner).expect("serializes");
2836        assert_eq!(json["backend"], "ws_claude_code");
2837        assert_eq!(json["variant"], "code-worker");
2838        assert_eq!(json["tools"], serde_json::json!(["Read", "Grep"]));
2839        let back: Runner = serde_json::from_value(json).expect("deserializes");
2840        assert_eq!(back, runner);
2841    }
2842
2843    #[test]
2844    fn runner_ws_operator_roundtrips_through_json_and_tags_backend() {
2845        let runner = Runner::WsOperator {
2846            variant: "mse-worker-reviewer".to_string(),
2847            tools: vec!["Read".to_string(), "Grep".to_string()],
2848        };
2849        let json = serde_json::to_value(&runner).expect("serializes");
2850        assert_eq!(json["backend"], "ws_operator");
2851        assert_eq!(json["variant"], "mse-worker-reviewer");
2852        assert_eq!(json["tools"], serde_json::json!(["Read", "Grep"]));
2853        let back: Runner = serde_json::from_value(json).expect("deserializes");
2854        assert_eq!(back, runner);
2855    }
2856
2857    #[test]
2858    fn runner_agent_block_in_process_roundtrips_through_json_and_tags_backend() {
2859        let runner = agent_block_runner(vec!["Bash"]);
2860        let json = serde_json::to_value(&runner).expect("serializes");
2861        assert_eq!(json["backend"], "agent_block_in_process");
2862        assert_eq!(json["tools"], serde_json::json!(["Bash"]));
2863        let back: Runner = serde_json::from_value(json).expect("deserializes");
2864        assert_eq!(back, runner);
2865    }
2866
2867    #[test]
2868    fn runner_tools_omitted_when_empty() {
2869        let runner = ws_runner("code-worker", vec![]);
2870        let json = serde_json::to_value(&runner).expect("serializes");
2871        assert!(
2872            json.as_object().unwrap().get("tools").is_none(),
2873            "tools key must be absent when empty: {json}"
2874        );
2875        let back: Runner = serde_json::from_value(json).expect("deserializes");
2876        assert_eq!(back, runner);
2877    }
2878
2879    #[test]
2880    fn runner_rejects_unknown_field() {
2881        let json = serde_json::json!({
2882            "backend": "ws_claude_code",
2883            "variant": "x",
2884            "not_a_real_field": true,
2885        });
2886        let err = serde_json::from_value::<Runner>(json).unwrap_err();
2887        assert!(
2888            err.to_string().contains("not_a_real_field")
2889                || err.to_string().contains("unknown field"),
2890            "expected an unknown-field rejection, got: {err}"
2891        );
2892    }
2893
2894    #[test]
2895    fn runner_def_roundtrips_through_json() {
2896        let def = RunnerDef {
2897            name: "claude-worker".to_string(),
2898            runner: ws_runner("code-worker", vec!["Read"]),
2899        };
2900        let json = serde_json::to_value(&def).expect("serializes");
2901        let back: RunnerDef = serde_json::from_value(json).expect("deserializes");
2902        assert_eq!(back, def);
2903    }
2904
2905    // ─── GH #83: SubprocessDef / Runner::Subprocess ────────────────
2906
2907    fn sample_subprocess_def(name: &str) -> SubprocessDef {
2908        SubprocessDef {
2909            name: name.to_string(),
2910            argv: vec![
2911                "sh".to_string(),
2912                "-c".to_string(),
2913                "echo '{\"result\": \"ok\"}'".to_string(),
2914            ],
2915            stdin: Some("{prompt}".to_string()),
2916            env: std::collections::BTreeMap::from([("EXTRA".to_string(), "{task_id}".to_string())]),
2917            cwd: Some("{work_dir}".to_string()),
2918            output: Some(SubprocessOutput {
2919                format: Some("json".to_string()),
2920                result_ptr: Some("/result".to_string()),
2921                ok_from: Some("exit_code".to_string()),
2922                stats: None,
2923            }),
2924            stream_mode: None,
2925        }
2926    }
2927
2928    #[test]
2929    fn subprocess_def_roundtrips_through_json() {
2930        let def = sample_subprocess_def("echo-json");
2931        let json = serde_json::to_value(&def).expect("serializes");
2932        let back: SubprocessDef = serde_json::from_value(json).expect("deserializes");
2933        assert_eq!(back, def);
2934    }
2935
2936    #[test]
2937    fn subprocess_def_optional_fields_omitted_when_default() {
2938        let def = SubprocessDef {
2939            name: "min".to_string(),
2940            argv: vec!["cat".to_string()],
2941            stdin: None,
2942            env: Default::default(),
2943            cwd: None,
2944            output: None,
2945            stream_mode: None,
2946        };
2947        let json = serde_json::to_value(&def).expect("serializes");
2948        let obj = json.as_object().unwrap();
2949        for absent in ["stdin", "env", "cwd", "output", "stream_mode"] {
2950            assert!(
2951                !obj.contains_key(absent),
2952                "{absent} key must be absent when default: {json}"
2953            );
2954        }
2955        let back: SubprocessDef = serde_json::from_value(json).expect("deserializes");
2956        assert_eq!(back, def);
2957    }
2958
2959    #[test]
2960    fn subprocess_def_rejects_unknown_field() {
2961        let json = serde_json::json!({
2962            "name": "x",
2963            "argv": ["cat"],
2964            "not_a_real_field": true,
2965        });
2966        let err = serde_json::from_value::<SubprocessDef>(json).unwrap_err();
2967        assert!(
2968            err.to_string().contains("unknown field"),
2969            "expected an unknown-field rejection, got: {err}"
2970        );
2971    }
2972
2973    #[test]
2974    fn blueprint_subprocesses_defaults_to_empty_and_stays_off_the_wire() {
2975        // Pre-#83 BP JSON (no `subprocesses` key) deserializes to an empty registry.
2976        let bp = minimal_bp(None);
2977        let json = serde_json::to_value(&bp).expect("serializes");
2978        assert!(
2979            json.as_object().unwrap().get("subprocesses").is_none(),
2980            "subprocesses key must be absent when empty: {json}"
2981        );
2982        let back: Blueprint = serde_json::from_value(json).expect("deserializes");
2983        assert!(back.subprocesses.is_empty());
2984    }
2985
2986    #[test]
2987    fn blueprint_subprocesses_roundtrips_when_declared() {
2988        let mut bp = minimal_bp(None);
2989        bp.subprocesses = vec![sample_subprocess_def("echo-json")];
2990        let json = serde_json::to_value(&bp).expect("serializes");
2991        assert_eq!(json["subprocesses"][0]["name"], "echo-json");
2992        let back: Blueprint = serde_json::from_value(json).expect("deserializes");
2993        assert_eq!(back.subprocesses, bp.subprocesses);
2994    }
2995
2996    #[test]
2997    fn runner_subprocess_roundtrips_through_json_and_tags_backend() {
2998        // 1:1 name symmetry with AgentKind::Subprocess — tag must be "subprocess".
2999        let runner = Runner::Subprocess {
3000            template: "echo-json".to_string(),
3001            overrides: SubprocessOverrides {
3002                model: Some("small".to_string()),
3003                tools: vec!["Read".to_string()],
3004                cwd: Some("/tmp/wd".to_string()),
3005            },
3006        };
3007        let json = serde_json::to_value(&runner).expect("serializes");
3008        assert_eq!(json["backend"], "subprocess");
3009        assert_eq!(json["template"], "echo-json");
3010        assert_eq!(json["overrides"]["model"], "small");
3011        let back: Runner = serde_json::from_value(json).expect("deserializes");
3012        assert_eq!(back, runner);
3013    }
3014
3015    #[test]
3016    fn runner_subprocess_overrides_omitted_when_empty() {
3017        let runner = Runner::Subprocess {
3018            template: "echo-json".to_string(),
3019            overrides: SubprocessOverrides::default(),
3020        };
3021        let json = serde_json::to_value(&runner).expect("serializes");
3022        assert!(
3023            json.as_object().unwrap().get("overrides").is_none(),
3024            "overrides key must be absent when all-default: {json}"
3025        );
3026        let back: Runner = serde_json::from_value(json).expect("deserializes");
3027        assert_eq!(back, runner);
3028    }
3029
3030    #[test]
3031    fn resolve_runner_inline_subprocess_variant_resolves() {
3032        let inline = Runner::Subprocess {
3033            template: "echo-json".to_string(),
3034            overrides: SubprocessOverrides::default(),
3035        };
3036        let agent = agent_with_runner("headless", None, Some(inline.clone()), None);
3037        let mut bp = minimal_bp(None);
3038        bp.agents = vec![agent.clone()];
3039
3040        let resolved = resolve_runner(&bp, &agent).expect("resolves");
3041        assert_eq!(resolved, Some(inline));
3042    }
3043
3044    #[test]
3045    fn resolve_runner_registry_and_default_tiers_resolve_subprocess_variant() {
3046        let registry_runner = Runner::Subprocess {
3047            template: "echo-json".to_string(),
3048            overrides: SubprocessOverrides::default(),
3049        };
3050        // Tier 2: runner_ref → registry.
3051        let agent = agent_with_runner("headless", None, None, Some("proc-entry".to_string()));
3052        let mut bp = minimal_bp(None);
3053        bp.runners = vec![RunnerDef {
3054            name: "proc-entry".to_string(),
3055            runner: registry_runner.clone(),
3056        }];
3057        bp.agents = vec![agent.clone()];
3058        let resolved = resolve_runner(&bp, &agent).expect("resolves");
3059        assert_eq!(resolved, Some(registry_runner.clone()));
3060
3061        // Tier 4: default_runner alone.
3062        let bare = agent_with_runner("headless", None, None, None);
3063        bp.agents = vec![bare.clone()];
3064        bp.default_runner = Some("proc-entry".to_string());
3065        let resolved = resolve_runner(&bp, &bare).expect("resolves");
3066        assert_eq!(resolved, Some(registry_runner));
3067    }
3068
3069    #[test]
3070    fn bind_outcome_bound_roundtrips_through_json_and_tags_outcome() {
3071        let outcome = BindOutcome::Bound {
3072            receipt: BindReceipt {
3073                agent: "coder".to_string(),
3074                request_digest: BindingDigest::sha256("req"),
3075                provider_id: "mse-provider".to_string(),
3076                provider_revision: Some("1".to_string()),
3077                resolved_model: Some("claude-sonnet-4".to_string()),
3078                effective_tools: vec!["Read".to_string(), "Write".to_string()],
3079                launch_variant: Some("mse-coder".to_string()),
3080                capability_snapshot_digest: None,
3081            },
3082        };
3083        let json = serde_json::to_value(&outcome).expect("serializes");
3084        assert_eq!(json["outcome"], "bound");
3085        assert_eq!(json["receipt"]["agent"], "coder");
3086        let back: BindOutcome = serde_json::from_value(json).expect("deserializes");
3087        assert_eq!(back, outcome);
3088    }
3089
3090    #[test]
3091    fn bind_outcome_unbound_roundtrips_through_json_and_tags_outcome() {
3092        let outcome = BindOutcome::Unbound {
3093            agent: "coder".to_string(),
3094            reason: "no capability for launch variant".to_string(),
3095        };
3096        let json = serde_json::to_value(&outcome).expect("serializes");
3097        assert_eq!(json["outcome"], "unbound");
3098        assert_eq!(json["agent"], "coder");
3099        assert_eq!(json["reason"], "no capability for launch variant");
3100        let back: BindOutcome = serde_json::from_value(json).expect("deserializes");
3101        assert_eq!(back, outcome);
3102    }
3103
3104    #[test]
3105    fn bind_outcome_rejects_unknown_field() {
3106        let json = serde_json::json!({
3107            "outcome": "unbound",
3108            "agent": "coder",
3109            "reason": "gone",
3110            "not_a_real_field": true,
3111        });
3112        let err = serde_json::from_value::<BindOutcome>(json).unwrap_err();
3113        assert!(
3114            err.to_string().contains("not_a_real_field")
3115                || err.to_string().contains("unknown field"),
3116            "expected an unknown-field rejection, got: {err}"
3117        );
3118    }
3119
3120    #[test]
3121    fn compiler_strategy_strict_binding_defaults_false_and_omitted() {
3122        let strategy = CompilerStrategy::default();
3123        assert!(!strategy.strict_binding);
3124        // Absent in JSON deserializes back to false.
3125        let back: CompilerStrategy = serde_json::from_value(serde_json::json!({
3126            "strict_refs": true,
3127            "strict_kind": true,
3128        }))
3129        .expect("deserializes without strict_binding");
3130        assert!(!back.strict_binding);
3131    }
3132
3133    #[test]
3134    fn worker_model_roundtrips_through_json() {
3135        let model = WorkerModel {
3136            runner: agent_block_runner(vec!["Bash"]),
3137            agent: agent_with_runner("coder", None, None, None),
3138        };
3139        let json = serde_json::to_value(&model).expect("serializes");
3140        let back: WorkerModel = serde_json::from_value(json).expect("deserializes");
3141        assert_eq!(back, model);
3142    }
3143
3144    // ─── resolve_runner cascade precedence ─────────────────────────
3145
3146    #[test]
3147    fn resolve_runner_inline_wins_over_everything() {
3148        let inline = agent_block_runner(vec!["Bash"]);
3149        let profile = AgentProfile {
3150            worker_binding: Some("legacy-variant".to_string()),
3151            tools: vec!["Read".to_string()],
3152            ..Default::default()
3153        };
3154        let agent = agent_with_runner(
3155            "coder",
3156            Some(profile),
3157            Some(inline.clone()),
3158            Some("registry-entry".to_string()),
3159        );
3160        let mut bp = minimal_bp(None);
3161        bp.default_runner = Some("registry-entry".to_string());
3162        bp.runners = vec![RunnerDef {
3163            name: "registry-entry".to_string(),
3164            runner: ws_runner("other-variant", vec![]),
3165        }];
3166        bp.agents = vec![agent.clone()];
3167
3168        let resolved = resolve_runner(&bp, &agent).expect("resolves");
3169        assert_eq!(resolved, Some(inline));
3170    }
3171
3172    #[test]
3173    fn resolve_runner_runner_ref_wins_over_legacy_fallback() {
3174        let profile = AgentProfile {
3175            worker_binding: Some("legacy-variant".to_string()),
3176            tools: vec!["Read".to_string()],
3177            ..Default::default()
3178        };
3179        let registry_runner = ws_runner("registry-variant", vec!["Grep"]);
3180        let agent = agent_with_runner(
3181            "coder",
3182            Some(profile),
3183            None,
3184            Some("registry-entry".to_string()),
3185        );
3186        let mut bp = minimal_bp(None);
3187        bp.runners = vec![RunnerDef {
3188            name: "registry-entry".to_string(),
3189            runner: registry_runner.clone(),
3190        }];
3191        bp.agents = vec![agent.clone()];
3192
3193        let resolved = resolve_runner(&bp, &agent).expect("resolves");
3194        assert_eq!(resolved, Some(registry_runner));
3195    }
3196
3197    #[test]
3198    fn resolve_runner_legacy_fallback_wins_over_default_runner() {
3199        let profile = AgentProfile {
3200            worker_binding: Some("legacy-variant".to_string()),
3201            tools: vec!["Read".to_string(), "Grep".to_string()],
3202            ..Default::default()
3203        };
3204        let agent = agent_with_runner("coder", Some(profile), None, None);
3205        let mut bp = minimal_bp(None);
3206        bp.default_runner = Some("registry-entry".to_string());
3207        bp.runners = vec![RunnerDef {
3208            name: "registry-entry".to_string(),
3209            runner: agent_block_runner(vec!["Bash"]),
3210        }];
3211        bp.agents = vec![agent.clone()];
3212
3213        let resolved = resolve_runner(&bp, &agent).expect("resolves");
3214        assert_eq!(
3215            resolved,
3216            Some(ws_runner("legacy-variant", vec!["Read", "Grep"]))
3217        );
3218    }
3219
3220    #[test]
3221    fn resolve_runner_default_runner_alone_when_no_agent_level_declaration() {
3222        let agent = agent_with_runner("coder", None, None, None);
3223        let mut bp = minimal_bp(None);
3224        bp.default_runner = Some("registry-entry".to_string());
3225        bp.runners = vec![RunnerDef {
3226            name: "registry-entry".to_string(),
3227            runner: agent_block_runner(vec!["Bash"]),
3228        }];
3229        bp.agents = vec![agent.clone()];
3230
3231        let resolved = resolve_runner(&bp, &agent).expect("resolves");
3232        assert_eq!(resolved, Some(agent_block_runner(vec!["Bash"])));
3233    }
3234
3235    #[test]
3236    fn resolve_runner_none_when_nothing_declared_through_any_tier() {
3237        let agent = agent_with_runner("coder", None, None, None);
3238        let bp = minimal_bp(None);
3239
3240        let resolved = resolve_runner(&bp, &agent).expect("resolves");
3241        assert_eq!(resolved, None);
3242    }
3243
3244    #[test]
3245    fn resolve_runner_unknown_runner_ref_errs() {
3246        let agent = agent_with_runner("coder", None, None, Some("no-such-entry".to_string()));
3247        let mut bp = minimal_bp(None);
3248        bp.runners = vec![RunnerDef {
3249            name: "registry-entry".to_string(),
3250            runner: agent_block_runner(vec![]),
3251        }];
3252        bp.agents = vec![agent.clone()];
3253
3254        let err = resolve_runner(&bp, &agent).expect_err("unresolved runner_ref");
3255        assert_eq!(
3256            err,
3257            RunnerResolveError::UnknownRunnerRef {
3258                agent: "coder".to_string(),
3259                ref_name: "no-such-entry".to_string(),
3260                available: vec!["registry-entry".to_string()],
3261            }
3262        );
3263    }
3264
3265    #[test]
3266    fn resolve_runner_unknown_default_runner_errs() {
3267        let agent = agent_with_runner("coder", None, None, None);
3268        let mut bp = minimal_bp(None);
3269        bp.default_runner = Some("no-such-entry".to_string());
3270        bp.runners = vec![RunnerDef {
3271            name: "registry-entry".to_string(),
3272            runner: agent_block_runner(vec![]),
3273        }];
3274        bp.agents = vec![agent.clone()];
3275
3276        let err = resolve_runner(&bp, &agent).expect_err("unresolved default_runner");
3277        assert_eq!(
3278            err,
3279            RunnerResolveError::UnknownDefaultRunner {
3280                ref_name: "no-such-entry".to_string(),
3281                available: vec!["registry-entry".to_string()],
3282            }
3283        );
3284    }
3285
3286    #[test]
3287    fn bound_agent_digest_is_stable_and_tracks_runner_changes() {
3288        let agent = agent_with_runner(
3289            "coder",
3290            None,
3291            Some(ws_runner("worker-a", vec!["Read"])),
3292            None,
3293        );
3294        let mut bp = minimal_bp(None);
3295        bp.agents = vec![agent];
3296
3297        let first = resolve_bound_agents(&bp).expect("binds");
3298        let second = resolve_bound_agents(&bp).expect("binds again");
3299        assert_eq!(first[0].binding_digest, second[0].binding_digest);
3300        assert!(first[0].binding_digest.as_str().starts_with("sha256:"));
3301        assert_eq!(first[0].binding_digest.as_str().len(), 71);
3302        assert_eq!(first[0].runner_source, RunnerResolutionSource::AgentInline);
3303
3304        bp.agents[0].runner = Some(ws_runner("worker-b", vec!["Read"]));
3305        let changed = resolve_bound_agents(&bp).expect("binds changed runner");
3306        assert_ne!(first[0].binding_digest, changed[0].binding_digest);
3307    }
3308
3309    #[test]
3310    fn bound_agent_pins_effective_context_policy_and_full_agent() {
3311        let mut agent = agent_with_runner("scout", None, None, None);
3312        agent.profile = Some(AgentProfile {
3313            system_prompt: "inspect carefully".to_string(),
3314            ..Default::default()
3315        });
3316        let mut bp = minimal_bp(None);
3317        bp.default_context_policy = Some(ContextPolicy {
3318            include: Some(vec!["task".to_string()]),
3319            ..Default::default()
3320        });
3321        bp.agents = vec![agent];
3322
3323        let bound = resolve_bound_agents(&bp).expect("binds").remove(0);
3324        assert_eq!(
3325            bound.agent.profile.unwrap().system_prompt,
3326            "inspect carefully"
3327        );
3328        assert_eq!(
3329            bound.context_policy.unwrap().include,
3330            Some(vec!["task".to_string()])
3331        );
3332        assert_eq!(bound.runner_source, RunnerResolutionSource::None);
3333    }
3334
3335    #[test]
3336    fn strict_bound_agent_resolution_rejects_legacy_worker_binding() {
3337        let profile = AgentProfile {
3338            worker_binding: Some("legacy-worker".to_string()),
3339            ..Default::default()
3340        };
3341        let mut bp = minimal_bp(None);
3342        bp.agents = vec![agent_with_runner("coder", Some(profile), None, None)];
3343
3344        let err = resolve_bound_agents_strict(&bp).expect_err("legacy must fail closed");
3345        assert!(matches!(
3346            err,
3347            BoundAgentResolveError::LegacyWorkerBindingDisabled { agent } if agent == "coder"
3348        ));
3349    }
3350
3351    #[test]
3352    fn binding_digest_is_a_validated_transparent_string() {
3353        use std::str::FromStr as _;
3354
3355        let digest = BindingDigest::sha256(b"same snapshot");
3356        let json = serde_json::to_value(&digest).expect("serializes");
3357        assert_eq!(json, serde_json::Value::String(digest.to_string()));
3358        assert_eq!(
3359            serde_json::from_value::<BindingDigest>(json).expect("deserializes"),
3360            digest
3361        );
3362        for invalid in [
3363            "deadbeef",
3364            "sha256:abc",
3365            "sha256:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA",
3366            "blake3:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
3367        ] {
3368            assert!(
3369                BindingDigest::from_str(invalid).is_err(),
3370                "accepted {invalid}"
3371            );
3372        }
3373    }
3374
3375    #[test]
3376    fn capability_snapshot_digest_accepts_the_legacy_wire_name() {
3377        let digest = BindingDigest::sha256("capabilities");
3378        let capability: AgentProviderCapability = serde_json::from_value(serde_json::json!({
3379            "launch_variant": "coder",
3380            "effective_tools": ["Read"],
3381            "evidence_digest": digest,
3382        }))
3383        .expect("legacy manifest remains readable");
3384        assert_eq!(capability.capability_snapshot_digest, Some(digest.clone()));
3385
3386        let serialized = serde_json::to_value(capability).expect("serialize new wire shape");
3387        assert_eq!(serialized["capability_snapshot_digest"], digest.to_string());
3388        assert!(serialized.get("evidence_digest").is_none());
3389    }
3390
3391    // ──────────────────────────────────────────────────────────────
3392    // GH #50: `AgentDef.verdict` / `VerdictContract` / `VerdictChannel`
3393    // ──────────────────────────────────────────────────────────────
3394
3395    #[test]
3396    fn verdict_contract_roundtrips_body_channel() {
3397        let json = serde_json::json!({"channel": "body", "values": ["PASS", "BLOCKED"]});
3398        let contract: VerdictContract = serde_json::from_value(json.clone()).expect("deserializes");
3399        assert_eq!(contract.channel, VerdictChannel::Body);
3400        assert_eq!(
3401            contract.values,
3402            vec!["PASS".to_string(), "BLOCKED".to_string()]
3403        );
3404        assert_eq!(serde_json::to_value(&contract).expect("serializes"), json);
3405    }
3406
3407    #[test]
3408    fn verdict_contract_roundtrips_part_channel() {
3409        let json = serde_json::json!({"channel": "part", "values": ["ALLOW"]});
3410        let contract: VerdictContract = serde_json::from_value(json.clone()).expect("deserializes");
3411        assert_eq!(contract.channel, VerdictChannel::Part);
3412        assert_eq!(serde_json::to_value(&contract).expect("serializes"), json);
3413    }
3414
3415    #[test]
3416    fn agent_def_verdict_omitted_when_none() {
3417        let agent = agent_with_runner("gate", None, None, None);
3418        let json = serde_json::to_value(&agent).expect("serializes");
3419        assert!(
3420            json.as_object().unwrap().get("verdict").is_none(),
3421            "verdict key must be absent when None: {json}"
3422        );
3423        let back: AgentDef = serde_json::from_value(json).expect("deserializes");
3424        assert_eq!(back.verdict, None);
3425    }
3426
3427    #[test]
3428    fn agent_def_verdict_roundtrips_when_some() {
3429        let mut agent = agent_with_runner("gate", None, None, None);
3430        agent.verdict = Some(VerdictContract {
3431            channel: VerdictChannel::Body,
3432            values: vec!["PASS".to_string(), "BLOCKED".to_string()],
3433        });
3434        let json = serde_json::to_value(&agent).expect("serializes");
3435        let back: AgentDef = serde_json::from_value(json).expect("deserializes");
3436        assert_eq!(back.verdict, agent.verdict);
3437    }
3438
3439    /// Acceptance criterion #2: the `02-verdict-loop.json` sample (no
3440    /// `verdict` field on any of its agents) must still deserialize
3441    /// unchanged under the new `#[serde(deny_unknown_fields)]`-constrained
3442    /// `AgentDef` — `verdict` is `#[serde(default)]`, so its absence is not
3443    /// an error.
3444    #[test]
3445    fn existing_verdict_loop_sample_deserializes_with_verdict_omitted() {
3446        const SAMPLE: &str =
3447            include_str!("../../mlua-swarm-cli/src/mcp/resources/samples/02-verdict-loop.json");
3448        let bp: Blueprint = serde_json::from_str(SAMPLE).expect("sample deserializes");
3449        assert_eq!(bp.agents.len(), 6);
3450        assert!(
3451            bp.agents.iter().all(|a| a.verdict.is_none()),
3452            "no agent in the sample declares a verdict contract"
3453        );
3454    }
3455
3456    // ──────────────────────────────────────────────────────────────
3457    // CheckPolicy enum relocation + Blueprint.check_policy
3458    // (T1: schema round-trip / omit→None / invalid→error)
3459    // ──────────────────────────────────────────────────────────────
3460
3461    /// The wire form is snake_case and byte-identical to the pre-relocation
3462    /// enum (`"silent"` / `"warn"` / `"strict"`), round-tripping in both
3463    /// directions — the relocation must not change the serde surface.
3464    #[test]
3465    fn check_policy_wire_form_round_trips() {
3466        for (variant, wire) in [
3467            (CheckPolicy::Silent, "silent"),
3468            (CheckPolicy::Warn, "warn"),
3469            (CheckPolicy::Strict, "strict"),
3470        ] {
3471            let json = serde_json::to_value(variant).expect("serializes");
3472            assert_eq!(json, serde_json::json!(wire), "wire form for {variant:?}");
3473            let back: CheckPolicy = serde_json::from_value(json).expect("deserializes");
3474            assert_eq!(back, variant, "round-trip for {variant:?}");
3475        }
3476    }
3477
3478    /// The default is `Warn` (preserves the pre-CheckPolicy fail-open
3479    /// behaviour of every submit-time projection sink).
3480    #[test]
3481    fn check_policy_default_is_warn() {
3482        assert_eq!(CheckPolicy::default(), CheckPolicy::Warn);
3483    }
3484
3485    /// A Blueprint that declares `check_policy: "strict"` parses to
3486    /// `Some(Strict)` and re-serializes with the same snake_case literal.
3487    #[test]
3488    fn blueprint_check_policy_strict_round_trips() {
3489        let json = serde_json::json!({
3490            "schema_version": current_schema_version(),
3491            "id": "check-policy-strict-ut",
3492            "flow": { "kind": "seq", "children": [] },
3493            "check_policy": "strict",
3494        });
3495        let bp: Blueprint = serde_json::from_value(json).expect("deserializes");
3496        assert_eq!(bp.check_policy, Some(CheckPolicy::Strict));
3497        let re = serde_json::to_string(&bp).expect("serializes");
3498        assert!(
3499            re.contains("\"check_policy\":\"strict\""),
3500            "re-serialized BP must preserve the snake_case wire literal: {re}"
3501        );
3502    }
3503
3504    /// An omitted `check_policy` parses to `None` and is skipped on
3505    /// serialize (backward-compat with every pre-cascade Blueprint).
3506    #[test]
3507    fn blueprint_check_policy_omitted_is_none() {
3508        let json = serde_json::json!({
3509            "schema_version": current_schema_version(),
3510            "id": "check-policy-omitted-ut",
3511            "flow": { "kind": "seq", "children": [] },
3512        });
3513        let bp: Blueprint = serde_json::from_value(json).expect("deserializes");
3514        assert_eq!(bp.check_policy, None);
3515
3516        let out = serde_json::to_value(&bp).expect("serializes");
3517        assert!(
3518            out.as_object().unwrap().get("check_policy").is_none(),
3519            "check_policy key must be absent when None: {out}"
3520        );
3521    }
3522
3523    /// An invalid `check_policy` value is a hard parse error (not silently
3524    /// dropped) — the enum is closed to the three snake_case variants. This
3525    /// also confirms `deny_unknown_fields` is not the gate here: the field
3526    /// IS known, only its value is invalid.
3527    #[test]
3528    fn blueprint_check_policy_invalid_value_errors() {
3529        let json = serde_json::json!({
3530            "schema_version": current_schema_version(),
3531            "id": "check-policy-invalid-ut",
3532            "flow": { "kind": "seq", "children": [] },
3533            "check_policy": "loud",
3534        });
3535        let err = serde_json::from_value::<Blueprint>(json)
3536            .expect_err("an unknown check_policy value must be rejected");
3537        let msg = err.to_string();
3538        assert!(
3539            msg.contains("check_policy") || msg.contains("loud") || msg.contains("variant"),
3540            "error should point at the bad check_policy value: {msg}"
3541        );
3542    }
3543
3544    #[test]
3545    fn agent_provider_manifest_round_trips_and_rejects_unknown_fields() {
3546        let json = serde_json::json!({
3547            "provider_id": "main-ai-self-report",
3548            "provider_revision": "1",
3549            "capabilities": [{
3550                "launch_variant": "mse-coder",
3551                "resolved_model": "claude-sonnet-4",
3552                "effective_tools": ["Read", "Edit"]
3553            }]
3554        });
3555        let manifest: AgentProviderManifest =
3556            serde_json::from_value(json.clone()).expect("manifest deserializes");
3557        assert_eq!(serde_json::to_value(manifest).unwrap(), json);
3558
3559        let invalid = serde_json::json!({
3560            "provider_id": "main-ai-self-report",
3561            "capabilities": [],
3562            "platform_secret": true
3563        });
3564        assert!(serde_json::from_value::<AgentProviderManifest>(invalid).is_err());
3565    }
3566}