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