Skip to main content

rustev_surface/
plan.rs

1//! `plan check | compile | show` (spec 006, 3.4), for every host.
2
3use rustev_contract::Document;
4use rustev_contract::definition::Definition;
5use rustev_contract::execution::ExecutionPolicy;
6use rustev_contract::limits::{DEFINITION_V1, DESCRIPTOR_V1, PLAN_V1};
7use rustev_contract::plan::{BoundCalibration, Plan, PlanExecution, PlanStepDetail};
8use rustev_core::compile::{Category, ShortfallReason};
9use rustev_core::{Compiled, LoadError, Refusal, compile, compile_with};
10use serde_json::{Value, json};
11
12use crate::deps::{self, DepArgs, DepFail, Deps, RulesHook};
13use crate::host::Host;
14use crate::out::{Done, Out, Res, code};
15
16/// Snake case of a refusal category (spec 006, 3.4.2).
17pub fn category_name(c: Category) -> &'static str {
18    match c {
19        Category::Parse => "parse",
20        Category::UnknownOperator => "unknown_operator",
21        Category::KindMismatch => "kind_mismatch",
22        Category::Cycle => "cycle",
23        Category::NoCapableBackend => "no_capable_backend",
24        Category::UncalibratedThreshold => "uncalibrated_threshold",
25        Category::Budget => "budget",
26        Category::UnhandledUnresolved => "unhandled_unresolved",
27        Category::InvalidFallback => "invalid_fallback",
28        Category::CalibrationBinding => "calibration_binding",
29        Category::InvalidDefinition => "invalid_definition",
30        Category::InvalidExecution => "invalid_execution",
31        Category::GradeRequirement => "grade_requirement",
32        Category::UncorroboratedHint => "uncorroborated_hint",
33        Category::SourceBinding => "source_binding",
34        // Spec 033, 3.11.1: printed like every category.
35        Category::UncoveredProvenance => "uncovered_provenance",
36        Category::CallerCannotAct => "caller_cannot_act",
37        Category::InvalidScreening => "invalid_screening",
38        Category::UnscreenedText => "unscreened_text",
39        Category::InvalidAcquisition => "invalid_acquisition",
40        Category::SourceMapping => "source_mapping",
41        Category::InvalidGeneration => "invalid_generation",
42        Category::Template => "template",
43        Category::ModelOutputFlow => "model_output_flow",
44    }
45}
46
47fn reason_json(r: &ShortfallReason) -> Value {
48    match r {
49        ShortfallReason::ScreenProfile => json!({"kind": "screen_profile"}),
50        ShortfallReason::ScreenTextLimit { max, needed } => {
51            json!({"kind": "screen_text_limit", "max": max, "needed": needed})
52        }
53        ShortfallReason::ScreenInputExcess => json!({"kind": "screen_input_excess"}),
54        ShortfallReason::OutputLimit { max, needed } => {
55            json!({"kind": "output_limit", "max": max, "needed": needed})
56        }
57        ShortfallReason::Language { required } => json!({"kind": "language", "required": required}),
58        ShortfallReason::UnknownBackend => json!({"kind": "unknown_backend"}),
59        ShortfallReason::Operation(op) => json!({"kind": "operation", "operation": op}),
60        ShortfallReason::OutputKind { offered, required } => {
61            json!({"kind": "output_kind", "offered": offered, "required": required})
62        }
63        ShortfallReason::Options { max, needed } => {
64            json!({"kind": "options", "max": max, "needed": needed})
65        }
66        ShortfallReason::InputLimit { max, needed } => {
67            json!({"kind": "input_limit", "max": max, "needed": needed})
68        }
69        ShortfallReason::Determinism { offered, required } => {
70            json!({"kind": "determinism", "offered": offered, "required": required})
71        }
72        ShortfallReason::ArtifactPin { pinned, offered } => {
73            json!({"kind": "artifact_pin", "pinned": pinned, "offered": offered})
74        }
75    }
76}
77
78/// The `refused` output of a compile refusal.
79pub fn refused(command: &str, r: &Refusal) -> Done {
80    let shortfalls: Vec<Value> = r
81        .shortfalls
82        .iter()
83        .map(|s| {
84            json!({
85                "backend_id": s.backend_id,
86                "reasons": s.reasons.iter().map(reason_json).collect::<Vec<_>>(),
87            })
88        })
89        .collect();
90    Done::new(
91        code::REFUSED,
92        Out::new(command, "refused")
93            .set("category", &(r.category as u8))
94            .set("category_name", category_name(r.category))
95            .set("subject", &r.subject)
96            .set("detail", &r.detail)
97            .set("shortfalls", &shortfalls),
98    )
99}
100
101fn parse_refusal(subject: String, detail: String) -> Refusal {
102    Refusal {
103        category: Category::Parse,
104        subject,
105        detail,
106        shortfalls: vec![],
107    }
108}
109
110/// The `refused` output of a load diagnostic (spec 004, 5.2).
111pub fn load_refused(command: &str, e: &LoadError) -> Done {
112    let out = Out::new(command, "refused").set("detail", &e.to_string());
113    let out = match e {
114        LoadError::CompilerChanged {
115            recorded_compiler,
116            current_compiler,
117            recorded_registry,
118            current_registry,
119        } => out.set("load_error", "compiler_changed").set(
120            "identities",
121            &json!({
122                "recorded_compiler": recorded_compiler,
123                "current_compiler": current_compiler,
124                "recorded_registry": recorded_registry,
125                "current_registry": current_registry,
126            }),
127        ),
128        LoadError::MissingDescriptor {
129            backend_id,
130            descriptor,
131        } => out
132            .set("load_error", "missing_descriptor")
133            .set("backend_id", backend_id)
134            .set("descriptor", descriptor),
135        LoadError::MissingCalibration { step, calibration } => out
136            .set("load_error", "missing_calibration")
137            .set("step", step)
138            .set("calibration", calibration),
139        LoadError::PlanMismatch { refusal } => {
140            let out = out.set("load_error", "plan_mismatch");
141            match refusal {
142                Some(r) => out.set(
143                    "refusal",
144                    &json!({
145                        "category": r.category as u8,
146                        "category_name": category_name(r.category),
147                        "subject": r.subject,
148                        "detail": r.detail,
149                    }),
150                ),
151                None => out,
152            }
153        }
154    };
155    Done::new(code::REFUSED, out)
156}
157
158/// Map a dependency failure to its output.
159pub fn dep_failed(command: &str, e: DepFail) -> Done {
160    match e {
161        DepFail::Io(e) => Done::io(command, e),
162        DepFail::Parse { subject, detail } => refused(command, &parse_refusal(subject, detail)),
163        DepFail::Rules { path, detail } => Done::invalid(command, &path, detail),
164    }
165}
166
167/// Read and parse a plan document; a malformed one is a category 1 refusal.
168pub fn read_plan(command: &str, host: &dyn Host, path: &str) -> Result<Plan, Done> {
169    let bytes = host
170        .read(path, PLAN_V1.max_bytes)
171        .map_err(|e| Done::io(command, e))?;
172    #[cfg(not(feature = "parameterized"))]
173    rustev_core::unparameterized::plan_bytes(&bytes).map_err(|r| refused(command, &r))?;
174    Plan::parse(&bytes).map_err(|e| {
175        refused(
176            command,
177            &parse_refusal(format!("plan:{path}"), e.to_string()),
178        )
179    })
180}
181
182/// Load a plan with `load_checked` against `deps`.
183pub fn load_plan(command: &str, plan: &Plan, deps: &Deps) -> Result<Compiled, Done> {
184    Compiled::load_checked(plan, &deps.descriptors, &deps.calibrations)
185        .map_err(|e| load_refused(command, &e))
186}
187
188/// What `plan check` and `plan compile` read.
189#[derive(Debug, Clone, Copy)]
190pub struct CompileArgs<'a> {
191    pub definition: &'a str,
192    pub deps: DepArgs<'a>,
193    pub execution: Option<&'a str>,
194}
195
196fn compile_definition(
197    command: &str,
198    host: &dyn Host,
199    args: CompileArgs<'_>,
200    rules: &mut RulesHook<'_>,
201) -> Result<Compiled, Done> {
202    let def_path = args.definition;
203    let bytes = host
204        .read(def_path, DEFINITION_V1.max_bytes)
205        .map_err(|e| Done::io(command, e))?;
206    compile_definition_bytes(command, host, args, rules, &bytes)
207}
208
209pub(crate) fn compile_definition_bytes(
210    command: &str,
211    host: &dyn Host,
212    args: CompileArgs<'_>,
213    rules: &mut RulesHook<'_>,
214    bytes: &[u8],
215) -> Result<Compiled, Done> {
216    // As `compile_bytes`: a malformed definition is category 1, subject
217    // `definition`.
218    #[cfg(not(feature = "parameterized"))]
219    rustev_core::unparameterized::definition(bytes).map_err(|r| refused(command, &r))?;
220    let def = Definition::parse(bytes)
221        .map_err(|e| refused(command, &parse_refusal("definition".into(), e.to_string())))?;
222    let deps = deps::load(host, args.deps, rules).map_err(|e| dep_failed(command, e))?;
223    let execution = match args.execution {
224        None => None,
225        Some(path) => {
226            let bytes = host
227                .read(path, DESCRIPTOR_V1.max_bytes)
228                .map_err(|e| Done::io(command, e))?;
229            Some(ExecutionPolicy::parse(&bytes).map_err(|e| {
230                refused(
231                    command,
232                    &parse_refusal(format!("execution:{path}"), e.to_string()),
233                )
234            })?)
235        }
236    };
237    match &execution {
238        None => compile(&def, &deps.descriptors, &deps.calibrations),
239        Some(x) => compile_with(&def, &deps.descriptors, &deps.calibrations, x),
240    }
241    .map_err(|r| refused(command, &r))
242}
243
244/// `plan check`: the plan identity of a definition that compiles.
245pub fn check(
246    command: &str,
247    host: &dyn Host,
248    args: CompileArgs<'_>,
249    rules: &mut RulesHook<'_>,
250) -> Res {
251    let c = compile_definition(command, host, args, rules)?;
252    Ok(checked(command, &c))
253}
254
255pub(crate) fn checked(command: &str, c: &Compiled) -> Done {
256    Done::ok(Out::new(command, "ok").set("plan_id", &c.id))
257}
258
259/// `plan compile`: the plan's canonical bytes published as `out`.
260pub fn compile_cmd(
261    command: &str,
262    host: &dyn Host,
263    args: CompileArgs<'_>,
264    rules: &mut RulesHook<'_>,
265    out: &str,
266) -> Res {
267    host.ensure_absent(out).map_err(|e| Done::io(command, e))?;
268    let c = compile_definition(command, host, args, rules)?;
269    publish_compiled(command, host, &c, out)
270}
271
272pub(crate) fn publish_compiled(command: &str, host: &dyn Host, c: &Compiled, out: &str) -> Res {
273    host.create(out, &c.canonical())
274        .map_err(|e| Done::io(command, e))?;
275    Ok(Done::ok(
276        Out::new(command, "compiled")
277            .set("plan_id", &c.id)
278            .set("out", out),
279    ))
280}
281
282fn step_json(step: &rustev_contract::plan::PlanStep) -> Value {
283    let detail = match &step.detail {
284        PlanStepDetail::Exact { op, version } => {
285            json!({"kind": "exact", "op": op, "version": version})
286        }
287        PlanStepDetail::Semantic(b) => json!({
288            "kind": "semantic",
289            "backend_id": b.backend_id,
290            "artifact": b.artifact,
291            "descriptor": b.descriptor,
292            "output": b.output,
293            "requires": b.requires,
294            "normalization": b.normalization,
295            "calibration": match &b.calibration {
296                BoundCalibration::None => json!("none"),
297                BoundCalibration::Bound { id, .. } => json!(id),
298            },
299            "fallback_taken": b.fallback_taken,
300            "max_requests": b.max_requests,
301        }),
302        PlanStepDetail::Unsupported { capability } => {
303            json!({"kind": "unsupported", "capability": capability})
304        }
305        PlanStepDetail::Generate(_) => json!({"kind": "generate"}),
306        PlanStepDetail::Corroborate => json!({"kind": "corroborate"}),
307        PlanStepDetail::Acquire => json!({"kind": "acquire"}),
308        PlanStepDetail::Widen => json!({"kind": "widen"}),
309    };
310    json!({"id": step.id, "derivation": step.derivation, "detail": detail})
311}
312
313/// `plan show`: a plan's identities, bindings and steps.
314pub fn show(
315    command: &str,
316    host: &dyn Host,
317    plan: &str,
318    deps_args: DepArgs<'_>,
319    rules: &mut RulesHook<'_>,
320) -> Res {
321    let path = plan;
322    let plan = read_plan(command, host, plan)?;
323    // Without `parameterized`, `read_plan` has already refused any plan with
324    // an instantiation record (spec 050), so this returns at its first line.
325    let out = instantiation(command, path, Out::new(command, "ok"), &plan)?;
326    let deps = deps::load(host, deps_args, rules).map_err(|e| dep_failed(command, e))?;
327    let c = load_plan(command, &plan, &deps)?;
328    let plan = &c.plan;
329    let (execution, fallbacks) = match &plan.execution {
330        PlanExecution::None => (json!("none"), vec![]),
331        PlanExecution::Declared(d) => (
332            json!(d.id),
333            d.fallbacks
334                .iter()
335                .map(|f| {
336                    json!({
337                        "step": f.step,
338                        "target": f.target,
339                        "backend_id": f.backend_id,
340                        "artifact": f.artifact,
341                        "descriptor": f.descriptor,
342                        "output": f.output,
343                    })
344                })
345                .collect(),
346        ),
347    };
348    let def = &plan.definition;
349    let out = out
350        .set("plan_id", &c.id)
351        .set("compiler", &plan.compiler)
352        .set("registry", &plan.registry)
353        .set("definition_id", &plan.definition_id)
354        .set(
355            "definition",
356            &json!({"package": def.package, "name": def.name, "version": def.version}),
357        )
358        .set("execution", &execution)
359        .set("fallbacks", &fallbacks)
360        .set("order", &plan.order)
361        .set(
362            "steps",
363            &plan.steps.iter().map(step_json).collect::<Vec<_>>(),
364        );
365    Ok(Done::ok(actionability(out, plan)))
366}
367
368/// A plan with `actionability` adds its declarations and, per check, each
369/// branch's classes, consequence kind and static party (spec 033, 3.11.2);
370/// every other plan prints exactly as before.
371fn actionability(out: Out, plan: &rustev_contract::plan::Plan) -> Out {
372    let (Some(a), Some(table)) = (&plan.definition.actionability, &plan.actionability) else {
373        return out;
374    };
375    out.set("caller_controlled", &a.caller_controlled)
376        .set(
377            "checks",
378            &table
379                .checks
380                .iter()
381                .map(|c| {
382                    json!({
383                        "id": c.check,
384                        "subject": c.subject,
385                        "branches": c.branches.iter().map(|b| json!({
386                            "classes": b.classes,
387                            "consequence": b.consequence,
388                            "party": b.party,
389                        })).collect::<Vec<_>>(),
390                    })
391                })
392                .collect::<Vec<_>>(),
393        )
394        .set(
395            "constraint_groups",
396            &a.constraint_groups
397                .iter()
398                .map(|g| json!({"name": g.name, "checks": g.checks}))
399                .collect::<Vec<_>>(),
400        )
401        .set(
402            "remediations",
403            &a.remediations
404                .iter()
405                .map(|r| json!({"code": r.code, "for": r.for_party}))
406                .collect::<Vec<_>>(),
407        )
408        .set(
409            "questions",
410            &a.questions.iter().map(|q| &q.id).collect::<Vec<_>>(),
411        )
412}
413
414/// Conservatively leave v3 plans without an instantiation record unchanged.
415fn instantiation(command: &str, path: &str, out: Out, plan: &Plan) -> Result<Out, Done> {
416    let Some(i) = &plan.instantiation else {
417        return Ok(out);
418    };
419    let version = i.template.get("version").and_then(Value::as_str);
420    let version = version
421        .filter(|v| v.parse::<rustev_contract::version::Version>().is_ok())
422        .ok_or_else(|| {
423            refused(
424                command,
425                &parse_refusal(
426                    format!("plan:{path}"),
427                    "instantiation.template.version must be a string containing a valid definition version (MAJOR.MINOR.PATCH without leading zeros or suffixes)".into(),
428                ),
429            )
430        })?;
431    Ok(out.set("plan_schema", &plan.schema).set(
432        "instantiation",
433        &json!({
434            "template_id": i.template_id, "template_version": version,
435            "variant": i.variant, "chain": i.profile,
436            "parameters": i.parameters.iter().map(|p| json!({
437                "name": p.name, "value": p.value, "source": p.source,
438                "site_count": p.sites.len(),
439            })).collect::<Vec<_>>(),
440        }),
441    ))
442}