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