Skip to main content

mlua_swarm_schema/
lib.rs

1//! Blueprint schema — Swarm IF SoT (= the core type set that defines "how a Blueprint object is written").
2//!
3//! This crate provides **schema types + serde derives only** as a pure IF crate. Execution
4//! layers (SpawnerFactory / EngineDispatcher / Compiler) are not included here; consumers
5//! (the `mlua-swarm` crate) own them. External consumers, sibling worktrees, and
6//! future bundles can read/write Blueprints by depending on this single crate.
7//!
8//! # Versioning contract
9//!
10//! `Blueprint.schema_version` is tied to this crate's semver. It is fixed at 0.1.0 for now;
11//! during 0.x breaking changes are free, and 1.0 will freeze the schema.
12//!
13//! # IN-immutability (extension discipline)
14//!
15//! This crate is the IN side of the swarm layering and stays **plain serde
16//! data**: no compile pass, no field the engine macro-expands, no DSL
17//! dialect. Flow conds are written literally against the Flow.ir Expr set
18//! (`Eq($.<step>.verdict, Lit("blocked"))` — domain verdicts are plain
19//! strings in step output). Authoring sugar (builders) lives OUT on the
20//! consumer side; runtime behavior extension lives in the engine's
21//! `SpawnerLayer` middleware.
22//!
23//! # AgentKind handling (= internal SoT)
24//!
25//! [`AgentKind`] is the SoT for the SpawnerAdapter offering axis. It is a closed enum managed
26//! inside Swarm, extended by variant addition through **explicit maintenance**. String lookup
27//! or a `Custom` escape hatch is deliberately avoided (= structurally eliminates the "silly
28//! runtime typos" class of failures).
29//!
30//! # Examples
31//!
32//! Build a minimal [`Blueprint`] with a single [`AgentDef`] via struct literal:
33//!
34//! ```
35//! use mlua_swarm_schema::{
36//!     AgentDef, AgentKind, Blueprint, current_schema_version,
37//! };
38//! use mlua_flow_ir::{Expr, Node};
39//! use serde_json::json;
40//!
41//! let bp = Blueprint {
42//!     schema_version: current_schema_version(),
43//!     id: "hello".into(),
44//!     flow: Node::Step {
45//!         ref_: "greeter".into(),
46//!         in_: Expr::Lit { value: json!({"name": "world"}) },
47//!         out: Expr::Path { at: "$.greeting".into() },
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//!     }],
56//!     operators: vec![],
57//!     metas: vec![],
58//!     hints: Default::default(),
59//!     strategy: Default::default(),
60//!     metadata: Default::default(),
61//!     spawner_hints: Default::default(),
62//!     default_agent_kind: AgentKind::Operator,
63//!     default_operator_kind: None,
64//!     default_init_ctx: None,
65//!     default_agent_ctx: None,
66//!     default_context_policy: None,
67//! };
68//!
69//! assert_eq!(bp.id.as_str(), "hello");
70//! assert_eq!(bp.agents.len(), 1);
71//! assert_eq!(bp.strategy.strict_refs, true);
72//! ```
73//!
74//! Round-trip a [`Blueprint`] through JSON (= confirms `serde` derives and the
75//! `deny_unknown_fields` contract):
76//!
77//! ```
78//! use mlua_swarm_schema::{AgentKind, Blueprint, BlueprintMetadata};
79//! use mlua_flow_ir::{Expr, Node};
80//! use serde_json::json;
81//!
82//! let bp = Blueprint {
83//!     schema_version: mlua_swarm_schema::current_schema_version(),
84//!     id: "roundtrip".into(),
85//!     flow: Node::Seq { children: vec![] },
86//!     agents: vec![],
87//!     operators: vec![],
88//!     metas: vec![],
89//!     hints: Default::default(),
90//!     strategy: Default::default(),
91//!     metadata: BlueprintMetadata {
92//!         description: Some("roundtrip smoke".into()),
93//!         default_run_ttl_secs: Some(1800),
94//!         ..Default::default()
95//!     },
96//!     spawner_hints: Default::default(),
97//!     default_agent_kind: AgentKind::Operator,
98//!     default_operator_kind: None,
99//!     default_init_ctx: None,
100//!     default_agent_ctx: None,
101//!     default_context_policy: None,
102//! };
103//!
104//! let json = serde_json::to_string(&bp).unwrap();
105//! let back: Blueprint = serde_json::from_str(&json).unwrap();
106//! assert_eq!(bp, back);
107//! assert_eq!(back.metadata.default_run_ttl_secs, Some(1800));
108//! ```
109
110#![warn(missing_docs)]
111
112use mlua_flow_ir::Node as FlowNode;
113use schemars::JsonSchema;
114use serde::{Deserialize, Serialize};
115use serde_json::Value;
116use std::collections::HashMap;
117
118// ──────────────────────────────────────────────────────────────────────────
119// Versioning
120// ──────────────────────────────────────────────────────────────────────────
121
122/// Current Blueprint schema version. Tied to this crate's semver.
123pub const CURRENT_SCHEMA_VERSION: &str = "0.1.0";
124
125fn default_schema_version() -> semver::Version {
126    current_schema_version()
127}
128
129/// Blueprint construction helper: returns the semver of the current schema version.
130/// Callers can write `schema_version: current_schema_version(),`.
131pub fn current_schema_version() -> semver::Version {
132    semver::Version::parse(CURRENT_SCHEMA_VERSION)
133        .expect("CURRENT_SCHEMA_VERSION must be valid semver")
134}
135
136// ──────────────────────────────────────────────────────────────────────────
137// BlueprintId (human-facing ID newtype)
138// ──────────────────────────────────────────────────────────────────────────
139
140/// Identifier for a Blueprint series — the domain name (`coding`,
141/// `design`, `testing`, etc.). Default: [`BlueprintId::main`].
142///
143/// One representation across the workspace (issue #14): this type is
144/// shared by the schema's [`Blueprint::id`] and the engine's store-layer
145/// keys (`mlua-swarm` re-exports it at the old
146/// `blueprint::store::types::BlueprintId` path). The value is
147/// user-supplied — there is no prefix convention to validate, unlike the
148/// engine's minted `T-` / `R-` / `ST-` ids — so construction is
149/// infallible; the inner string is private so call sites go through
150/// [`BlueprintId::new`] and the accessors. `#[serde(transparent)]` keeps
151/// both the JSON wire shape and the generated JSON Schema a plain string.
152#[derive(
153    Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize, JsonSchema,
154)]
155#[serde(transparent)]
156pub struct BlueprintId(String);
157
158impl BlueprintId {
159    /// The default series name used when a caller doesn't pick one.
160    pub const MAIN: &'static str = "main";
161
162    /// Shorthand for `BlueprintId::new(BlueprintId::MAIN)`.
163    pub fn main() -> Self {
164        Self(Self::MAIN.to_string())
165    }
166
167    /// Wrap any string-like value as a `BlueprintId` (user-supplied key;
168    /// nothing to validate).
169    pub fn new(s: impl Into<String>) -> Self {
170        Self(s.into())
171    }
172
173    /// Borrow the inner series name.
174    pub fn as_str(&self) -> &str {
175        &self.0
176    }
177
178    /// Consume the id and return the inner series name.
179    pub fn into_string(self) -> String {
180        self.0
181    }
182}
183
184impl std::fmt::Display for BlueprintId {
185    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
186        f.write_str(&self.0)
187    }
188}
189
190impl From<String> for BlueprintId {
191    fn from(s: String) -> Self {
192        Self(s)
193    }
194}
195
196impl From<&str> for BlueprintId {
197    fn from(s: &str) -> Self {
198        Self(s.to_string())
199    }
200}
201
202#[cfg(test)]
203mod blueprint_id_tests {
204    use super::*;
205
206    /// issue #14 convergence guard: `Blueprint.id` becoming a newtype must
207    /// not change the generated JSON Schema — the property stays an inline
208    /// plain string (no `$ref`), byte-compatible with the `String` era.
209    #[test]
210    fn blueprint_id_field_schema_stays_a_plain_inline_string() {
211        let schema = schemars::schema_for!(Blueprint);
212        let v = serde_json::to_value(&schema).expect("schema serializes");
213        let id = &v["properties"]["id"];
214        assert_eq!(id["type"], "string", "id must stay a plain string: {id}");
215        assert!(id.get("$ref").is_none(), "id must not become a $ref: {id}");
216    }
217
218    /// The JSON wire shape of the newtype is the bare string.
219    #[test]
220    fn blueprint_id_serde_is_transparent() {
221        let id = BlueprintId::new("coding");
222        assert_eq!(
223            serde_json::to_value(&id).unwrap(),
224            serde_json::json!("coding")
225        );
226        let back: BlueprintId = serde_json::from_value(serde_json::json!("coding")).unwrap();
227        assert_eq!(back, id);
228    }
229}
230
231// ──────────────────────────────────────────────────────────────────────────
232// Blueprint (top-level package)
233// ──────────────────────────────────────────────────────────────────────────
234
235/// Unified package of flow.ir + Swarm extension layers. The entry-point type of Swarm.
236#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, JsonSchema)]
237#[serde(deny_unknown_fields)]
238pub struct Blueprint {
239    /// Schema version (= tied to this crate's semver). Default = `CURRENT_SCHEMA_VERSION`.
240    /// Serialized as a semver string (e.g. `"0.1.0"`).
241    #[serde(default = "default_schema_version")]
242    #[schemars(with = "String")]
243    pub schema_version: semver::Version,
244    /// Blueprint identifier (= unique key within the caller's namespace).
245    #[schemars(with = "String")]
246    pub id: BlueprintId,
247    /// Embeds the flow.ir Node verbatim (= keeps flow.ir side unpolluted).
248    /// Opaque in the JSON Schema (the Node shape is owned by the `mlua-flow-ir`
249    /// crate, a separate repo; see its docs for the Node / Expr grammar).
250    #[schemars(with = "Value")]
251    pub flow: FlowNode,
252    /// Swarm extension layer: agent → backend mapping.
253    #[serde(default)]
254    pub agents: Vec<AgentDef>,
255    /// Swarm extension layer: **design-time definition** of Operator roles (first-class).
256    ///
257    /// `AgentDef.spec.operator_ref` references an `OperatorDef.name` (logical role name) in
258    /// this vec. Embedding runtime-generated IDs such as sid into the BP is forbidden
259    /// (= collapses the design-time vs runtime boundary). Runtime backend bindings are
260    /// established via the attach / register path; the BP side holds only logical names.
261    ///
262    /// Every `kind = Operator` agent must have its `spec.operator_ref` present in this
263    /// list — the compiler validates it at `compile()` time. May be `[]` only when the
264    /// Blueprint declares no Operator agents.
265    #[serde(default)]
266    pub operators: Vec<OperatorDef>,
267    /// GH #21 Phase 2 — named, BP-scoped pool of [`MetaDef`] entries. Two
268    /// independent consumers resolve names against this pool: a
269    /// `$step_meta.ref` envelope embedded in a Step's evaluated `in`
270    /// value (the Step tier — resolved by `EngineDispatcher` in the
271    /// `mlua-swarm` core crate at dispatch time), and
272    /// [`AgentMeta::meta_ref`] (the Agent tier — resolved at launch
273    /// time). The pool lets multiple Steps and/or Agents share one
274    /// declarative context object by name instead of repeating it
275    /// inline. `[]` = no named `MetaDef`s declared (pre-#21-Phase-2
276    /// Blueprints unaffected).
277    #[serde(default, skip_serializing_if = "Vec::is_empty")]
278    pub metas: Vec<MetaDef>,
279    /// Swarm extension layer: per-agent hints (interpreted by the Compiler).
280    #[serde(default)]
281    pub hints: CompilerHints,
282    /// Swarm extension layer: Compiler behavior strategy (strict / lenient).
283    #[serde(default)]
284    pub strategy: CompilerStrategy,
285    /// Blueprint metadata (description / origin / tags / ttl / version label / alias).
286    #[serde(default)]
287    pub metadata: BlueprintMetadata,
288    /// Swarm extension layer: hint keys of the layers to wrap around the SpawnerStack.
289    /// Resolved by the LayerRegistry at engine bind time (= unregistered keys are silently
290    /// skipped). Flow / Blueprint do not hold middleware implementations (e.g. MainAIMiddleware)
291    /// directly; they only declare required capabilities as string keys (= implementations
292    /// live in the engine-side LayerRegistry).
293    #[serde(default)]
294    pub spawner_hints: SpawnerHints,
295    /// BP-wide default `AgentKind` (= fallback when `AgentDef.kind` is omitted).
296    /// Four-layer cascade: (1) Schema impl Default = Operator, (2) CLI
297    /// `--default-agent-kind`, (3) this field (BP JSON literal), (4) `AgentDef.kind`
298    /// (per-agent literal). (5) `CompilerHints.kind_override` allows runtime override.
299    /// All default resolution flows through this path.
300    #[serde(default = "default_global_agent_kind")]
301    pub default_agent_kind: AgentKind,
302    /// BP-wide default `OperatorKind` (= the "BP Global" tier of the 4-tier
303    /// `OperatorKind` cascade). `None` when the Blueprint author does not
304    /// declare a default; the caller-side resolver then falls through to
305    /// the hardcoded `OperatorKind::default()` (Automate).
306    ///
307    /// # 4-tier cascade (highest to lowest priority)
308    ///
309    /// 1. Runtime Agent-level (per-agent override supplied at task-launch time)
310    /// 2. Runtime Global (the launch-time `operator_kind` request)
311    /// 3. BP Agent-level (`OperatorDef.kind`, resolved via `AgentDef.spec.operator_ref`)
312    /// 4. BP Global (this field)
313    /// 5. Default Fallback (`OperatorKind::default()` = Automate)
314    ///
315    /// The collapse itself is implemented once on the engine side and consumed
316    /// per-agent when resolving operator info.
317    #[serde(default, skip_serializing_if = "Option::is_none")]
318    pub default_operator_kind: Option<OperatorKind>,
319    /// Blueprint-level default initial `ctx` for flow-ir eval.
320    /// `TaskLaunchService::launch` shallow-merges this with the
321    /// Task-level `init_ctx` (Task wins on key collision when both
322    /// are `Object`; if Task's `init_ctx` is not an `Object`, it
323    /// full-replaces the default). `None` — no default is merged;
324    /// backward-compat with pre-#19 Blueprints.
325    #[serde(default, skip_serializing_if = "Option::is_none")]
326    #[schemars(with = "Option<Value>")]
327    pub default_init_ctx: Option<Value>,
328    /// GH #21 Phase 1 — "BP Global" tier of the agent-context supply axis:
329    /// a declarative object merged into `ctx.meta.runtime` (and, for
330    /// unnamed keys, `AgentContextView.extra`) targeting every agent's
331    /// runtime materialization. Contrast with [`Self::default_init_ctx`]:
332    /// that field seeds the flow-ir eval `ctx` once at flow start, while
333    /// this one is consumed per-spawn by
334    /// `AgentContextMiddleware`/`AgentContextView` (Contract C, GH #20) —
335    /// a pure flow-ir eval seed vs. an Agent/LLM-boundary runtime default.
336    /// `None` = no BP-global default (pre-#21 Blueprints unaffected).
337    #[serde(default, skip_serializing_if = "Option::is_none")]
338    #[schemars(with = "Option<Value>")]
339    pub default_agent_ctx: Option<Value>,
340    /// GH #21 Phase 1 — "BP Global" tier of the [`ContextPolicy`] cascade:
341    /// the default filter applied to the materialized `AgentContextView`
342    /// when the targeted agent declares no `AgentMeta.context_policy` of
343    /// its own. `None` = pass-all (the pre-#21 behavior).
344    #[serde(default, skip_serializing_if = "Option::is_none")]
345    pub default_context_policy: Option<ContextPolicy>,
346}
347
348/// Receptacle for a Blueprint-driven filter over the materialized
349/// `AgentContextView` (GH #20/#21). Declared BP-side via
350/// [`Blueprint::default_context_policy`] (BP-global) or
351/// `AgentMeta::context_policy` (per-agent, outranks the BP-global tier) —
352/// resolved and applied by `AgentContextMiddleware` in the `mlua-swarm`
353/// core crate (this crate stays execution-free; see the crate doc).
354/// Default (`include: None, exclude: vec![]`) is pass-all — [`Self::allows`]
355/// returns `true` for every field name.
356#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, JsonSchema)]
357#[serde(deny_unknown_fields)]
358pub struct ContextPolicy {
359    /// Field names to keep. `None` means "keep everything" (pass-all).
360    /// Matched against the `AgentContextView` named-field strings
361    /// (`"project_root"` / `"work_dir"` / `"task_metadata"` / `"run_id"` /
362    /// `"project_name_alias"`) and `extra` keys by their own key string.
363    /// Identity fields (`task_id` / `agent` / `attempt`) are never
364    /// filtered regardless of `include`.
365    #[serde(default)]
366    pub include: Option<Vec<String>>,
367    /// Field names to drop, applied AFTER `include` (exclude wins when a
368    /// name appears in both). Same name-matching rule as `include`.
369    #[serde(default)]
370    pub exclude: Vec<String>,
371    /// Which preceding steps' OUTPUT pointers a worker's fetch payload may
372    /// see (`WorkerPayload.context.steps`, ST5 of the `projection-adapter`
373    /// design). `None` = pass-all (every submitted step, the pre-ST5
374    /// `ctx_step_dir` behavior); `Some(list)` = only the named steps;
375    /// `Some(vec![])` = none. Evaluated by [`Self::allows_step`], a sibling
376    /// of [`Self::allows`] with the same include/exclude precedence rule
377    /// but a separate namespace (step names vs. `AgentContextView` field /
378    /// `extra` key names never collide).
379    #[serde(default)]
380    pub steps: Option<Vec<String>>,
381    /// Step names to drop, applied AFTER `steps` (exclude wins when a name
382    /// appears in both). Same name-matching rule as `steps`.
383    #[serde(default)]
384    pub steps_exclude: Vec<String>,
385}
386
387impl ContextPolicy {
388    /// Whether `name` survives this policy: `false` if `exclude` lists it;
389    /// otherwise `true` when `include` is `None` (pass-all) or lists
390    /// `name`. Shared by both the schema crate (tests) and the `mlua-swarm`
391    /// core crate's `AgentContextView::apply_policy`, so the include/exclude
392    /// evaluation rule has exactly one implementation.
393    pub fn allows(&self, name: &str) -> bool {
394        if self.exclude.iter().any(|excluded| excluded == name) {
395            return false;
396        }
397        match &self.include {
398            Some(list) => list.iter().any(|included| included == name),
399            None => true,
400        }
401    }
402
403    /// Whether the preceding step named `name` survives this policy for the
404    /// worker fetch payload's `context.steps` pointer list: `false` if
405    /// `steps_exclude` lists it; otherwise `true` when `steps` is `None`
406    /// (pass-all) or lists `name`. Same precedence rule as [`Self::allows`],
407    /// evaluated against the separate `steps` / `steps_exclude` fields.
408    pub fn allows_step(&self, name: &str) -> bool {
409        if self.steps_exclude.iter().any(|excluded| excluded == name) {
410            return false;
411        }
412        match &self.steps {
413            Some(list) => list.iter().any(|included| included == name),
414            None => true,
415        }
416    }
417}
418
419/// Global default `AgentKind` at the Schema impl Default layer. Bottom of the 4-layer cascade.
420pub fn default_global_agent_kind() -> AgentKind {
421    AgentKind::Operator
422}
423
424/// Set of **capability hint keys** for the SpawnerLayer required by a Blueprint.
425///
426/// # Design rationale (= for the person who will reconstruct this later)
427///
428/// A Blueprint is a pure layer of flow.ir + agent name binding and holds no middleware
429/// **implementation**. Nevertheless there are cases where the caller must be told the BP
430/// needs certain **capabilities** — e.g. "MainAI hook required", "Operator delegate path
431/// required", operator role mode switching, presence/absence of senior escalation, and
432/// so on.
433///
434/// `spawner_hints.layers` is the place where those capabilities are declared as **string
435/// keys**. The engine-side `LayerRegistry` (= consumer crate) resolves key → factory and
436/// wraps the compiled routes with a `SpawnerStack`. The Blueprint does not import the
437/// concrete `MainAIMiddleware` type; it exposes intent through strings such as `"main_ai"`
438/// (= separates the pure Flow layer from implementation details).
439///
440/// # Canonical hint keys
441///
442/// - `"main_ai"` → `MainAIMiddleware` (= fires SpawnHook before/after when kind is MainAi/Composite)
443/// - `"senior_escalation"` → `SeniorEscalationMiddleware` (= fires SeniorBridge.ask on worker ok=false)
444/// - `"operator_delegate"` → `OperatorDelegateMiddleware` (= delegates the entire spawn to an external Operator.execute)
445///
446/// # Behavior of unregistered keys
447///
448/// If the engine-side LayerRegistry has no matching factory, the key is **silently skipped**
449/// (= lenient default). This preserves Blueprint portability (= an unsupported capability in
450/// another deployment falls back gracefully).
451#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, JsonSchema)]
452#[serde(deny_unknown_fields)]
453pub struct SpawnerHints {
454    /// Ordered list of layer hint keys to wrap around the SpawnerStack.
455    #[serde(default)]
456    pub layers: Vec<String>,
457}
458
459// ──────────────────────────────────────────────────────────────────────────
460// AgentDef / AgentKind / AgentProfile / AgentMeta
461// ──────────────────────────────────────────────────────────────────────────
462
463/// Maps an agent name to a Worker IMPL kind and its configuration. Referenced from flow.ir
464/// `Step.ref` by name.
465///
466/// # Design
467///
468/// `AgentDef.kind` directly expresses the **Worker IMPL axis** (= not the old Spawner axis).
469/// Dispatching to a host Spawner adapter (`InProcSpawner` / `ProcessSpawner` /
470/// `OperatorSpawner`) is done by an internal Resolver on the compiler side. The design goal
471/// is "do not make the caller aware of which Spawner hosts the Worker IMPL"; the caller
472/// (Blueprint author) sees only the WorkerIMPL viewpoint.
473///
474/// A Spawner-axis hint (= "which adapter would you prefer running this Worker on", as a
475/// priority list) will be added via a future `spawner_hint: Vec<Spawner>` field as a carry.
476/// The current internal Resolver is a fixed 1:1 mapping, so the field is unnecessary today.
477#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, JsonSchema)]
478#[serde(deny_unknown_fields)]
479pub struct AgentDef {
480    /// Agent name (= referenced from flow.ir `Step.ref`).
481    pub name: String,
482    /// Worker IMPL kind (= see [`AgentKind`]).
483    pub kind: AgentKind,
484    /// Free-form schema per kind. Interpreted by the SpawnerFactory.
485    #[serde(default)]
486    pub spec: Value,
487    /// Agent persona information (system_prompt / model / tools, etc.). Orthogonal to the
488    /// backend kind and is a first-class field. Expected to be populated by
489    /// `agent_md_loader` from the frontmatter + body of an `agent.md`. `None` = an agent
490    /// without a profile (= backend built solely from `spec`).
491    #[serde(default)]
492    pub profile: Option<AgentProfile>,
493    /// Agent-level metadata (description / version / tags).
494    #[serde(default)]
495    pub meta: Option<AgentMeta>,
496}
497
498/// Agent persona information. Orthogonal to the backend kind (Shell / InProc / Operator).
499///
500/// Populated by `agent_md_loader::load_dir` from the frontmatter and Markdown body of
501/// `agents/*.md` in agent-profiles. The backend (e.g. AgentBlockOperator) receives this
502/// struct at construction / dispatch time and consumes `system_prompt` as the LLM API
503/// system message and `model` / `tools` as configuration.
504///
505/// C-C-specific fields (`permissionMode` / `memory` / `abtest`, etc.) are dumped into
506/// `extras: Value`, and consumers that need them read them out. This is the escape hatch
507/// that keeps the schema future-proof rather than making it strict.
508#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default, JsonSchema)]
509#[serde(deny_unknown_fields)]
510pub struct AgentProfile {
511    /// Markdown body (= system prompt content).
512    #[serde(default)]
513    pub system_prompt: String,
514    /// LLM model identifier (e.g. `"sonnet"` / `"haiku"` / `"opus"`).
515    #[serde(default)]
516    pub model: Option<String>,
517    /// Reasoning effort (e.g. `"low"` / `"medium"` / `"high"`).
518    #[serde(default)]
519    pub effort: Option<String>,
520    /// List of available tool names (normalized from the CSV form in frontmatter).
521    #[serde(default)]
522    pub tools: Vec<String>,
523    /// Frontmatter `description`. A short one-line description.
524    #[serde(default)]
525    pub description: Option<String>,
526    /// C-C-specific / future-proof fields (permissionMode / memory / abtest / ...).
527    /// Shape is the leftover keys of the agent.md frontmatter dumped as a JSON object.
528    #[serde(default)]
529    pub extras: Value,
530    /// Content hash (blake3 32-byte hex) of the agent body (= `system_prompt`).
531    ///
532    /// # Purpose
533    ///
534    /// When the Enhance loop receives a Patch that replaces
535    /// `/agents/N/profile/system_prompt`, the post-hook in `patch_applier.lua`
536    /// recomputes this field (= new blake3 of the body) and updates it automatically.
537    /// This is the field that structurally prevents a Blueprint carrying a stale hash
538    /// from being committed.
539    ///
540    /// - `None` = hash not computed (= manually built agent, or a Blueprint predating this field)
541    /// - `Some(hex)` = latest hash at agent-profiles seed time or after PatchApplier
542    ///
543    /// Planned to be used as the cache-index key in `AgentStore`.
544    #[serde(default)]
545    pub version_hash: Option<String>,
546    /// Claude Code SubAgent definition name this agent binds to at spawn
547    /// time (e.g. "mse-worker-coder"). Why: the Blueprint is the single
548    /// source of truth for the declaration↔executor binding — an external
549    /// registry would duplicate what `tools` already declares and drift.
550    /// `None` is valid for agents whose operator backend never dispatches
551    /// a SubAgent (direct-LLM operators); WS thin-path operators require
552    /// it at compile time (see `Operator::requires_worker_binding`).
553    #[serde(default, skip_serializing_if = "Option::is_none")]
554    pub worker_binding: Option<String>,
555}
556
557/// SoT of the **Worker IMPL axis**. A closed enum managed inside Swarm and extended by
558/// variant addition through **explicit maintenance**. String lookup / escape hatches are
559/// deliberately not adopted.
560///
561/// This enum **expresses Worker IMPL directly**; dispatching to a host Spawner adapter is
562/// resolved by an internal Resolver on the compiler side (= callers see only the Worker
563/// IMPL viewpoint).
564///
565/// # Internal Resolver mapping (= currently a fixed 1:1, carry: priority list form)
566///
567/// | AgentKind | Host Spawner adapter |
568/// |---|---|
569/// | `Lua` | `InProcSpawner` (mlua VM eval) |
570/// | `RustFn` | `InProcSpawner` (Rust closure) |
571/// | `AgentBlock` | `InProcSpawner` (agent-block-core SDK in-process) |
572/// | `Subprocess` | `ProcessSpawner` (child process launch) |
573/// | `Operator` | `OperatorSpawner` (interactive role / Human-MainAI delegation) |
574#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash, JsonSchema)]
575#[serde(rename_all = "snake_case")]
576pub enum AgentKind {
577    /// Lua script eval through the mlua VM (= factory-side registry looked up by `spec.fn_id`).
578    Lua,
579    /// Rust closure (= factory-side registry looked up by `spec.fn_id`).
580    RustFn,
581    /// Headless LLM agent via the agent-block-core SDK (in-process).
582    AgentBlock,
583    /// Child-process launch (= `spec.program` + `args`, via the ProcessSpawner path).
584    Subprocess,
585    /// Interactive Operator role (= MainAI / Human delegation, `spec.operator_ref`).
586    Operator,
587}
588
589// ──────────────────────────────────────────────────────────────────────────
590// OperatorDef / OperatorKind
591// ──────────────────────────────────────────────────────────────────────────
592
593/// Kind axis of an Operator role (= "in which mode does this Operator run").
594/// Corresponds 1:1 with the engine's runtime `OperatorKind`. Kept as a schema
595/// duplicate so that BPs can be authored while depending only on this crate.
596#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default, JsonSchema)]
597#[serde(rename_all = "snake_case")]
598pub enum OperatorKind {
599    /// MainAI (= interactive AI Operator via WS client or SDK).
600    MainAi,
601    /// Automate (= normal spawn path, without human interception).
602    #[default]
603    Automate,
604    /// Composite (= MainAi + Automate running side by side).
605    Composite,
606}
607
608/// Design-time definition of an Operator role (first-class).
609///
610/// `AgentDef.spec.operator_ref` references this struct's `name` as a logical role name.
611/// Binding to a runtime backend (WS session / SDK / pool, etc.) is established via the
612/// attach path; the BP side only declares "under this logical name we expect an Operator
613/// of this Kind".
614///
615/// `spec` is an escape hatch for kind-specific config (WS endpoint / SDK profile / pool
616/// binding, etc.). Even when empty, declaring `name` + `kind` alone is enough for
617/// compile-time validation to succeed (= it guarantees that agent `operator_ref` values
618/// reference an existing definition).
619#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, JsonSchema)]
620#[serde(deny_unknown_fields)]
621pub struct OperatorDef {
622    /// Logical role name (= design-time symbol referenced from `AgentDef.spec.operator_ref`).
623    pub name: String,
624    /// Display name for UI / docs (optional).
625    #[serde(default)]
626    pub display_name: Option<String>,
627    /// Kind axis of the Operator (MainAi / Automate / Composite) — the "BP
628    /// Agent-level" tier of the 4-tier `OperatorKind` cascade (see
629    /// `Blueprint.default_operator_kind` for the full tier list). `None`
630    /// when this `OperatorDef` does not declare a kind; the resolver then
631    /// falls through to BP Global / Default Fallback for agents referencing
632    /// this role via `AgentDef.spec.operator_ref`.
633    #[serde(default)]
634    pub kind: Option<OperatorKind>,
635    /// Kind-specific config (WS endpoint / SDK profile / pool binding, etc.). Interpreted
636    /// by the factory.
637    #[serde(default)]
638    pub spec: Value,
639    /// Operator persona information (e.g. system_prompt template). Same shape as
640    /// `AgentDef.profile`. Used as a template when the Operator itself plays a "role".
641    /// If `None`, the agent-side profile is used instead.
642    #[serde(default)]
643    pub profile: Option<AgentProfile>,
644    /// Operator-level metadata (description / version / tags).
645    #[serde(default)]
646    pub meta: Option<AgentMeta>,
647}
648
649/// Named, multi-step-shared declarative context payload (GH #21 Phase 2).
650///
651/// Lives in the [`Blueprint::metas`] pool and is referenced by name from
652/// two independent consumers: a `$step_meta.ref` envelope embedded in a
653/// Step's evaluated `in` value (the Step tier, resolved by
654/// `EngineDispatcher::dispatch` in the `mlua-swarm` core crate at
655/// dispatch time — see `EngineDispatcher::with_step_metas`), and
656/// [`AgentMeta::meta_ref`] (the Agent tier, resolved at launch time and
657/// merged UNDER the agent's inline `AgentMeta::ctx`). The pool lets
658/// multiple Steps and/or Agents share one declarative context object by
659/// name instead of repeating it inline.
660#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, JsonSchema)]
661#[serde(deny_unknown_fields)]
662pub struct MetaDef {
663    /// Logical name (= referenced by `$step_meta.ref` and
664    /// `AgentMeta.meta_ref`; unique within [`Blueprint::metas`]).
665    pub name: String,
666    /// Declarative context payload. Consumers expect a JSON `Object` so
667    /// it can be shallow-merged with an `inline` override / an agent's
668    /// own `ctx` (a non-`Object` value is rejected — loudly at dispatch
669    /// time for the Step tier, defensively (warn + skip) at launch time
670    /// for the Agent tier); the shape is otherwise free-form.
671    pub ctx: Value,
672}
673
674/// Agent / Operator level metadata (description / version / tags).
675#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default, JsonSchema)]
676#[serde(deny_unknown_fields)]
677pub struct AgentMeta {
678    /// Short human-readable description.
679    #[serde(default)]
680    pub description: Option<String>,
681    /// Free-form version label.
682    #[serde(default)]
683    pub version: Option<String>,
684    /// Tag list for classification / routing.
685    #[serde(default)]
686    pub tags: Vec<String>,
687    /// GH #21 Phase 1 — "BP Agent-level" tier of the agent-context supply
688    /// axis: a declarative object merged into `ctx.meta.runtime` for this
689    /// agent's spawns, on top of (and winning over)
690    /// [`Blueprint::default_agent_ctx`]. See that field's doc for the
691    /// contrast with `default_init_ctx`. `None` = this agent declares no
692    /// per-agent context (the BP-global tier alone applies, if any).
693    #[serde(default, skip_serializing_if = "Option::is_none")]
694    #[schemars(with = "Option<Value>")]
695    pub ctx: Option<Value>,
696    /// GH #21 Phase 1 — "BP Agent-level" tier of the [`ContextPolicy`]
697    /// cascade: outranks [`Blueprint::default_context_policy`] for this
698    /// agent. `None` = fall through to the BP-global policy (or pass-all
699    /// if that is also `None`).
700    #[serde(default, skip_serializing_if = "Option::is_none")]
701    pub context_policy: Option<ContextPolicy>,
702    /// GH #21 Phase 2 — "BP Agent-level" tier of the [`MetaDef`] pool:
703    /// resolves against [`Blueprint::metas`] by name. The resolved
704    /// `ctx` sits UNDER this agent's inline [`Self::ctx`] (inline wins
705    /// on key collision). `None` = this agent declares no shared
706    /// `MetaDef` reference.
707    #[serde(default, skip_serializing_if = "Option::is_none")]
708    pub meta_ref: Option<String>,
709}
710
711// ──────────────────────────────────────────────────────────────────────────
712// Compiler hints / strategy
713// ──────────────────────────────────────────────────────────────────────────
714
715/// Per-agent overrides / hints. Interpreted by the Compiler / SpawnerFactory; not required.
716#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default, JsonSchema)]
717#[serde(deny_unknown_fields)]
718pub struct CompilerHints {
719    /// Agent name → per-agent hint (= passed to `SpawnerFactory.build`).
720    #[serde(default)]
721    pub per_agent: HashMap<String, Value>,
722    /// Global hints (= e.g. parallel limit, default timeout, ...).
723    #[serde(default)]
724    pub global: Value,
725}
726
727/// Compiler behavior rules. Controls strict / lenient handling and default fallback.
728#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, JsonSchema)]
729#[serde(deny_unknown_fields)]
730pub struct CompilerStrategy {
731    /// If `true` (default), an unresolved `Step.ref` is an error; if `false`, it falls
732    /// through to the default Spawner.
733    #[serde(default = "default_true")]
734    pub strict_refs: bool,
735    /// If `true` (default), an `AgentKind` missing from the registry is an error; if
736    /// `false`, it is skipped.
737    #[serde(default = "default_true")]
738    pub strict_kind: bool,
739}
740
741fn default_true() -> bool {
742    true
743}
744
745impl Default for CompilerStrategy {
746    fn default() -> Self {
747        Self {
748            strict_refs: true,
749            strict_kind: true,
750        }
751    }
752}
753
754// ──────────────────────────────────────────────────────────────────────────
755// Blueprint metadata / origin
756// ──────────────────────────────────────────────────────────────────────────
757
758/// Blueprint-level metadata (description / origin / tags / ttl / version label / alias).
759#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default, JsonSchema)]
760#[serde(deny_unknown_fields)]
761pub struct BlueprintMetadata {
762    /// Short human-readable description of the Blueprint.
763    #[serde(default)]
764    pub description: Option<String>,
765    /// Provenance record (inline / file / algocline).
766    #[serde(default)]
767    pub origin: BlueprintOrigin,
768    /// Tag list for classification / routing.
769    #[serde(default)]
770    pub tags: Vec<String>,
771    /// Optional SemVer label (= match target for `TaskPipeline VersionSelector::SemVerReq`).
772    /// Example: `"1.2.3"`. Rewritten by `EnhanceAdapter` on PATCH/MINOR/MAJOR bumps.
773    #[serde(default, skip_serializing_if = "Option::is_none")]
774    pub version_label: Option<String>,
775    /// Optional LDS session alias label. The Swarm engine itself does not apply this
776    /// (= it is free-form content); the value is expanded into the Spawn directive and
777    /// reaches the MainAI. The MainAI is expected to establish a task session via
778    /// `mcp__lds__session_create(root=..., alias=<this>)`, and to inject
779    /// `LDS Session Alias: <this>` verbatim into the SubAgent dispatch prompt body.
780    /// The SubAgent body then calls `mcp__lds__session_start(alias=<this>)` with the
781    /// received alias. Worktree ownership is thereby unified under a single session, and
782    /// cross-SubAgent / cross-worktree ownership blocks (= `not owned by this session`)
783    /// cannot fire structurally.
784    #[serde(default, skip_serializing_if = "Option::is_none")]
785    pub project_name_alias: Option<String>,
786    /// Optional default TTL (seconds) for tasks dispatched via this BP. Estimated by the
787    /// Blueprint author from the flow shape (agent count × expected duration per agent).
788    /// If `POST /v1/tasks` supplies `ttl_secs` explicitly, the body value wins; otherwise
789    /// this metadata field is used as the default; if both are absent, the server global
790    /// default (`default_run_ttl()` = 1800s) applies. Not needed for short chains (~5 min);
791    /// recommended for long chains (14 agents × several minutes = 30-60 min).
792    #[serde(default, skip_serializing_if = "Option::is_none")]
793    pub default_run_ttl_secs: Option<u64>,
794}
795
796/// Provenance record of a Blueprint.
797#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default, JsonSchema)]
798#[serde(tag = "kind", rename_all = "snake_case")]
799pub enum BlueprintOrigin {
800    /// Inline construction, e.g. via a Rust struct literal or test code.
801    #[default]
802    Inline,
803    /// Loaded from a file.
804    File {
805        /// Source file path.
806        path: String,
807    },
808    /// Emitted by an algocline strategy (traced by `session_id`).
809    Algo {
810        /// Algocline session identifier.
811        session_id: String,
812    },
813}
814
815#[cfg(test)]
816mod tests {
817    use super::*;
818
819    #[test]
820    fn schema_version_default_parses() {
821        let v = default_schema_version();
822        assert_eq!(v.to_string(), "0.1.0");
823    }
824
825    #[test]
826    fn current_schema_version_const_matches() {
827        assert_eq!(CURRENT_SCHEMA_VERSION, "0.1.0");
828    }
829
830    #[test]
831    fn blueprint_json_schema_exports_key_properties() {
832        let schema = schemars::schema_for!(Blueprint);
833        let v = serde_json::to_value(&schema).expect("schema serializes");
834        let props = v["properties"].as_object().expect("object schema");
835        for key in [
836            "schema_version",
837            "id",
838            "flow",
839            "agents",
840            "operators",
841            "metas",
842            "hints",
843            "strategy",
844            "metadata",
845            "spawner_hints",
846            "default_agent_kind",
847            "default_operator_kind",
848            "default_init_ctx",
849            "default_agent_ctx",
850            "default_context_policy",
851        ] {
852            assert!(props.contains_key(key), "missing property: {key}");
853        }
854        // semver override lands as a plain string
855        assert_eq!(v["properties"]["schema_version"]["type"], "string");
856        // enum variants (snake_case) survive into the schema (LLM author axis)
857        let dump = v.to_string();
858        assert!(dump.contains("agent_block"), "AgentKind variants in schema");
859        assert!(dump.contains("main_ai"), "OperatorKind variants in schema");
860        // nested defs are referenced (AgentDef reachable from agents[])
861        assert!(dump.contains("AgentDef"), "AgentDef definition in schema");
862    }
863
864    #[test]
865    fn agent_profile_worker_binding_roundtrips_when_some() {
866        let profile = AgentProfile {
867            worker_binding: Some("mse-worker-coder".to_string()),
868            ..Default::default()
869        };
870        let json = serde_json::to_value(&profile).expect("serializes");
871        assert_eq!(json["worker_binding"], "mse-worker-coder");
872        let back: AgentProfile = serde_json::from_value(json).expect("deserializes");
873        assert_eq!(back.worker_binding.as_deref(), Some("mse-worker-coder"));
874    }
875
876    #[test]
877    fn agent_profile_worker_binding_omitted_when_none() {
878        let profile = AgentProfile::default();
879        let json = serde_json::to_value(&profile).expect("serializes");
880        // `skip_serializing_if = "Option::is_none"` — the key must not appear at all.
881        assert!(
882            json.as_object().unwrap().get("worker_binding").is_none(),
883            "worker_binding key must be absent when None: {json}"
884        );
885        let back: AgentProfile = serde_json::from_value(json).expect("deserializes");
886        assert_eq!(back.worker_binding, None);
887    }
888
889    // ──────────────────────────────────────────────────────────────
890    // issue #19 ST3: `Blueprint.default_init_ctx`
891    // ──────────────────────────────────────────────────────────────
892
893    fn minimal_bp(default_init_ctx: Option<Value>) -> Blueprint {
894        Blueprint {
895            schema_version: current_schema_version(),
896            id: "bp-init-ctx-ut".into(),
897            flow: FlowNode::Seq { children: vec![] },
898            agents: vec![],
899            operators: vec![],
900            metas: vec![],
901            hints: Default::default(),
902            strategy: Default::default(),
903            metadata: Default::default(),
904            spawner_hints: Default::default(),
905            default_agent_kind: AgentKind::Operator,
906            default_operator_kind: None,
907            default_init_ctx,
908            default_agent_ctx: None,
909            default_context_policy: None,
910        }
911    }
912
913    #[test]
914    fn blueprint_default_init_ctx_roundtrips_when_some() {
915        let bp = minimal_bp(Some(serde_json::json!({ "seeded": true })));
916        let json = serde_json::to_string(&bp).expect("serializes");
917        let back: Blueprint = serde_json::from_str(&json).expect("deserializes");
918        assert_eq!(
919            back.default_init_ctx,
920            Some(serde_json::json!({ "seeded": true }))
921        );
922        assert_eq!(bp, back);
923    }
924
925    #[test]
926    fn blueprint_default_init_ctx_omitted_when_none() {
927        let bp = minimal_bp(None);
928        let json = serde_json::to_value(&bp).expect("serializes");
929        // `skip_serializing_if = "Option::is_none"` — the key must not appear at all
930        // (pre-#19 Blueprints round-trip byte-identical through this path).
931        assert!(
932            json.as_object().unwrap().get("default_init_ctx").is_none(),
933            "default_init_ctx key must be absent when None: {json}"
934        );
935        let back: Blueprint = serde_json::from_value(json).expect("deserializes");
936        assert_eq!(back.default_init_ctx, None);
937        assert_eq!(bp, back);
938    }
939
940    #[test]
941    fn blueprint_json_schema_exports_default_init_ctx_as_nullable_value() {
942        let schema = schemars::schema_for!(Blueprint);
943        let v = serde_json::to_value(&schema).expect("schema serializes");
944        assert!(
945            v["properties"]["default_init_ctx"].is_object(),
946            "default_init_ctx must appear in the exported schema: {v}"
947        );
948    }
949
950    // ──────────────────────────────────────────────────────────────
951    // issue #21 Phase 1: `Blueprint.default_agent_ctx` /
952    // `default_context_policy`, `AgentMeta.ctx` / `context_policy`,
953    // `ContextPolicy`
954    // ──────────────────────────────────────────────────────────────
955
956    #[test]
957    fn blueprint_default_agent_ctx_and_context_policy_roundtrip_when_some() {
958        let mut bp = minimal_bp(None);
959        bp.default_agent_ctx = Some(serde_json::json!({ "org_conventions": "x" }));
960        bp.default_context_policy = Some(ContextPolicy {
961            include: Some(vec!["project_root".to_string()]),
962            exclude: vec!["work_dir".to_string()],
963            ..Default::default()
964        });
965        let json = serde_json::to_string(&bp).expect("serializes");
966        let back: Blueprint = serde_json::from_str(&json).expect("deserializes");
967        assert_eq!(bp, back);
968        assert_eq!(
969            back.default_agent_ctx,
970            Some(serde_json::json!({ "org_conventions": "x" }))
971        );
972        assert_eq!(
973            back.default_context_policy,
974            Some(ContextPolicy {
975                include: Some(vec!["project_root".to_string()]),
976                exclude: vec!["work_dir".to_string()],
977                ..Default::default()
978            })
979        );
980    }
981
982    #[test]
983    fn blueprint_default_agent_ctx_and_context_policy_omitted_when_none() {
984        let bp = minimal_bp(None);
985        let json = serde_json::to_value(&bp).expect("serializes");
986        let obj = json.as_object().unwrap();
987        assert!(
988            obj.get("default_agent_ctx").is_none(),
989            "default_agent_ctx key must be absent when None: {json}"
990        );
991        assert!(
992            obj.get("default_context_policy").is_none(),
993            "default_context_policy key must be absent when None: {json}"
994        );
995        let back: Blueprint = serde_json::from_value(json).expect("deserializes");
996        assert_eq!(back.default_agent_ctx, None);
997        assert_eq!(back.default_context_policy, None);
998        assert_eq!(bp, back);
999    }
1000
1001    #[test]
1002    fn blueprint_json_schema_exports_agent_ctx_and_context_policy() {
1003        let schema = schemars::schema_for!(Blueprint);
1004        let v = serde_json::to_value(&schema).expect("schema serializes");
1005        assert!(
1006            v["properties"]["default_agent_ctx"].is_object(),
1007            "default_agent_ctx must appear in the exported schema: {v}"
1008        );
1009        assert!(
1010            v["properties"]["default_context_policy"].is_object(),
1011            "default_context_policy must appear in the exported schema: {v}"
1012        );
1013    }
1014
1015    #[test]
1016    fn agent_meta_ctx_and_context_policy_roundtrip_when_some() {
1017        let meta = AgentMeta {
1018            ctx: Some(serde_json::json!({ "k": "v" })),
1019            context_policy: Some(ContextPolicy {
1020                include: None,
1021                exclude: vec!["run_id".to_string()],
1022                ..Default::default()
1023            }),
1024            ..Default::default()
1025        };
1026        let json = serde_json::to_value(&meta).expect("serializes");
1027        let back: AgentMeta = serde_json::from_value(json).expect("deserializes");
1028        assert_eq!(back, meta);
1029    }
1030
1031    #[test]
1032    fn agent_meta_ctx_and_context_policy_omitted_when_none() {
1033        let meta = AgentMeta::default();
1034        let json = serde_json::to_value(&meta).expect("serializes");
1035        let obj = json.as_object().unwrap();
1036        assert!(
1037            obj.get("ctx").is_none(),
1038            "ctx key must be absent when None: {json}"
1039        );
1040        assert!(
1041            obj.get("context_policy").is_none(),
1042            "context_policy key must be absent when None: {json}"
1043        );
1044    }
1045
1046    #[test]
1047    fn agent_meta_json_schema_exports_ctx_context_policy_and_meta_ref() {
1048        let schema = schemars::schema_for!(AgentMeta);
1049        let v = serde_json::to_value(&schema).expect("schema serializes");
1050        let props = v["properties"].as_object().expect("object schema");
1051        for key in [
1052            "description",
1053            "version",
1054            "tags",
1055            "ctx",
1056            "context_policy",
1057            "meta_ref",
1058        ] {
1059            assert!(props.contains_key(key), "missing property: {key}");
1060        }
1061    }
1062
1063    // ──────────────────────────────────────────────────────────────
1064    // issue #21 Phase 2: `MetaDef`, `Blueprint.metas`, `AgentMeta.meta_ref`
1065    // ──────────────────────────────────────────────────────────────
1066
1067    #[test]
1068    fn meta_def_roundtrips_through_json() {
1069        let def = MetaDef {
1070            name: "heavy-scan".to_string(),
1071            ctx: serde_json::json!({ "work_dir": "/x" }),
1072        };
1073        let json = serde_json::to_value(&def).expect("serializes");
1074        let back: MetaDef = serde_json::from_value(json).expect("deserializes");
1075        assert_eq!(back, def);
1076    }
1077
1078    #[test]
1079    fn blueprint_metas_omitted_when_empty() {
1080        let bp = minimal_bp(None);
1081        let json = serde_json::to_value(&bp).expect("serializes");
1082        assert!(
1083            json.as_object().unwrap().get("metas").is_none(),
1084            "metas key must be absent when empty: {json}"
1085        );
1086        let back: Blueprint = serde_json::from_value(json).expect("deserializes");
1087        assert!(back.metas.is_empty());
1088        assert_eq!(bp, back);
1089    }
1090
1091    #[test]
1092    fn blueprint_metas_roundtrips_when_non_empty() {
1093        let mut bp = minimal_bp(None);
1094        bp.metas = vec![MetaDef {
1095            name: "heavy-scan".to_string(),
1096            ctx: serde_json::json!({ "work_dir": "/x" }),
1097        }];
1098        let json = serde_json::to_string(&bp).expect("serializes");
1099        let back: Blueprint = serde_json::from_str(&json).expect("deserializes");
1100        assert_eq!(bp, back);
1101        assert_eq!(back.metas.len(), 1);
1102        assert_eq!(back.metas[0].name, "heavy-scan");
1103    }
1104
1105    #[test]
1106    fn blueprint_json_schema_exports_metas() {
1107        let schema = schemars::schema_for!(Blueprint);
1108        let v = serde_json::to_value(&schema).expect("schema serializes");
1109        assert!(
1110            v["properties"]["metas"].is_object(),
1111            "metas must appear in the exported schema: {v}"
1112        );
1113        let dump = v.to_string();
1114        assert!(dump.contains("MetaDef"), "MetaDef definition in schema");
1115    }
1116
1117    #[test]
1118    fn agent_meta_meta_ref_roundtrips_when_some() {
1119        let meta = AgentMeta {
1120            meta_ref: Some("heavy-scan".to_string()),
1121            ..Default::default()
1122        };
1123        let json = serde_json::to_value(&meta).expect("serializes");
1124        assert_eq!(json["meta_ref"], "heavy-scan");
1125        let back: AgentMeta = serde_json::from_value(json).expect("deserializes");
1126        assert_eq!(back, meta);
1127    }
1128
1129    #[test]
1130    fn agent_meta_meta_ref_omitted_when_none() {
1131        let meta = AgentMeta::default();
1132        let json = serde_json::to_value(&meta).expect("serializes");
1133        assert!(
1134            json.as_object().unwrap().get("meta_ref").is_none(),
1135            "meta_ref key must be absent when None: {json}"
1136        );
1137        let back: AgentMeta = serde_json::from_value(json).expect("deserializes");
1138        assert_eq!(back.meta_ref, None);
1139    }
1140
1141    #[test]
1142    fn context_policy_default_allows_everything() {
1143        let policy = ContextPolicy::default();
1144        assert!(policy.allows("project_root"));
1145        assert!(policy.allows("anything"));
1146    }
1147
1148    #[test]
1149    fn context_policy_include_only_allows_listed_names() {
1150        let policy = ContextPolicy {
1151            include: Some(vec!["project_root".to_string()]),
1152            exclude: vec![],
1153            ..Default::default()
1154        };
1155        assert!(policy.allows("project_root"));
1156        assert!(!policy.allows("work_dir"));
1157    }
1158
1159    #[test]
1160    fn context_policy_exclude_wins_over_include() {
1161        let policy = ContextPolicy {
1162            include: Some(vec!["project_root".to_string()]),
1163            exclude: vec!["project_root".to_string()],
1164            ..Default::default()
1165        };
1166        assert!(!policy.allows("project_root"));
1167    }
1168
1169    #[test]
1170    fn context_policy_roundtrips_through_json() {
1171        let policy = ContextPolicy {
1172            include: Some(vec!["a".to_string(), "b".to_string()]),
1173            exclude: vec!["c".to_string()],
1174            ..Default::default()
1175        };
1176        let json = serde_json::to_value(&policy).expect("serializes");
1177        let back: ContextPolicy = serde_json::from_value(json).expect("deserializes");
1178        assert_eq!(back, policy);
1179    }
1180
1181    #[test]
1182    fn context_policy_default_roundtrips_as_empty_object() {
1183        let policy = ContextPolicy::default();
1184        let json = serde_json::to_value(&policy).expect("serializes");
1185        assert_eq!(
1186            json,
1187            serde_json::json!({
1188                "include": null,
1189                "exclude": [],
1190                "steps": null,
1191                "steps_exclude": [],
1192            })
1193        );
1194        let back: ContextPolicy = serde_json::from_value(json).expect("deserializes");
1195        assert_eq!(back, policy);
1196    }
1197
1198    // ──────────────────────────────────────────────────────────────
1199    // ST5 (`projection-adapter`): `ContextPolicy.steps` / `steps_exclude`
1200    // ──────────────────────────────────────────────────────────────
1201
1202    #[test]
1203    fn context_policy_steps_default_allows_every_step() {
1204        let policy = ContextPolicy::default();
1205        assert!(policy.allows_step("planner"));
1206        assert!(policy.allows_step("anything"));
1207    }
1208
1209    #[test]
1210    fn context_policy_steps_include_only_allows_listed_names() {
1211        let policy = ContextPolicy {
1212            steps: Some(vec!["planner".to_string()]),
1213            ..Default::default()
1214        };
1215        assert!(policy.allows_step("planner"));
1216        assert!(!policy.allows_step("coder"));
1217    }
1218
1219    #[test]
1220    fn context_policy_steps_empty_list_allows_none() {
1221        let policy = ContextPolicy {
1222            steps: Some(vec![]),
1223            ..Default::default()
1224        };
1225        assert!(!policy.allows_step("planner"));
1226    }
1227
1228    #[test]
1229    fn context_policy_steps_exclude_wins_over_steps() {
1230        let policy = ContextPolicy {
1231            steps: Some(vec!["planner".to_string()]),
1232            steps_exclude: vec!["planner".to_string()],
1233            ..Default::default()
1234        };
1235        assert!(!policy.allows_step("planner"));
1236    }
1237
1238    #[test]
1239    fn context_policy_steps_roundtrips_through_json() {
1240        let policy = ContextPolicy {
1241            steps: Some(vec!["planner".to_string(), "coder".to_string()]),
1242            steps_exclude: vec!["reviewer".to_string()],
1243            ..Default::default()
1244        };
1245        let json = serde_json::to_value(&policy).expect("serializes");
1246        let back: ContextPolicy = serde_json::from_value(json).expect("deserializes");
1247        assert_eq!(back, policy);
1248    }
1249}