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