Skip to main content

mlua_swarm_schema/
lib.rs

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