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