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