Command schemas
Every command has an input schema and an output schema (JSON Schema 2020-12), generated by
schemars from two Rust types:
- a contract: the parameters type, checked in front of the handler (see
crates/engine/src/schema/mod.rs); with the session’s check level at warn or strict, a misspelt key or a wrong-typed value is reported or refused instead of silently ignored; - a reply type: what a successful call returns.
engine.describe and the MCP tool describe_command serve both, so agents and scripts read a
command’s exact parameters instead of guessing from prose.
When the check fails
The tests in crates/engine/src/schema/coverage_tests.rs say what to do. A command without
schemas is only a warning for now (the test prints the list); it will become required once the
PR backlog is down, so give new commands both schemas now. Set SOLIDCRAFT_SCHEMA_RULE_STRICT=1
to make the missing-schema check fail today.
- “
x.yhas no input or output schema” (or “no input schema”, “no output schema”): a new command. Give it what it lacks, as below. Don’t add it toschema/unconverted.txt: that list holds only the commands that predate the rule. - “
x.yhas its schemas now: remove its line from unconverted.txt”: you converted an old command. Delete its line. - “
x.yis a planned! placeholder, which needs no line in unconverted.txt”: delete its line. - “unconverted.txt names commands that don’t exist”: a command was removed or renamed. Delete its line.
- “unconverted.txt must be sorted, each id once”: keep the list in byte order
(
LC_ALL=C sort). - “
x.yran with an unknown key in strict” or “x.yshould refuse with …”: it has a contract, but itsrunisn’tcrate::checked!("x.y", <its handler>)(step 5): the wrapper is missing or names another command’s id.
Writing a contract
1. List the keys the handler reads
The contract’s keys are exactly the keys the handler reads, no more and no fewer. Read the
handler and everything it passes p to:
- direct reads:
p.get("k"),params::expr(p, "k"),req_expr,str_,num,bool_,vec3,string_listand the like; features::add_feature(every feature command) readsname,body_name,body_namesandparticipants: take them withFeatureCommon. Whatparticipantschanges depends on the feature, and for some it changes nothing in the result: check where the feature is evaluated (crates/doc/src/eval.rs).participantstakes its description fromFeatureCommon, shared by every feature, so say what it does for this one elsewhere: where it and another key override each other (a primitive’stargetsgives way to it; an extrude’stargetsoverrides it), in that key’s description; where it has no effect, in the summary. For the primitives (box, cylinder, sphere, torus)add_featurealso readstargets; a new primitive must join theprimitivematch inadd_feature_in, and its contract declarestargets. The commands that combine bodies readoperation(and usuallytargets) in their own handlers;- keys read around the command:
frames::to_localreadsbody(andface_body) for commands that take picks; live, for a command with.live(…)(todaysketch.dragandsketch.edit_text): a call withlive: truegoes to the live runner, whichchecked!doesn’t wrap, so live calls aren’t checked yet. Leave those two on the list until the engine checks the live path too. A new command that needs a live runner still gets its contract (declareliveas an optionalbool); say in its pull request that its live calls aren’t checked.
A key the handler reads only in some situations (thin-wall keys with thin: true) is still a
field; its description says when it applies. Contracts don’t check combinations of keys
(sketch with face); the handler does.
2. Put it in the module for its command file
Contracts for cmd/features.rs live in schema/features.rs, for cmd/inspect.rs in
schema/inspect.rs, and so on, each with its tests in a file next to it
(schema/features_tests.rs). For a command file’s first contract, create the module and add its
pub mod line, sorted, under “The contracts, by command file” in schema/mod.rs. A new module
starts like this (keep the imports it uses):
#![allow(unused)]
fn main() {
//! Contracts and result types for the commands in `cmd/wedge.rs`.
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use super::types::{FeatureCommon, LengthExpr, OperationParam, PointExpr3, StringList, feature_common, present};
// …the parameters and reply types…
#[cfg(test)]
#[path = "wedge_tests.rs"]
mod tests;
}
3. Write the parameters type
#![allow(unused)]
fn main() {
/// Add a wedge primitive.
#[derive(Debug, Deserialize, JsonSchema)]
#[schemars(deny_unknown_fields)]
pub struct WedgeParams {
/// Size along X.
pub length: LengthExpr,
/// Size along Y.
pub width: LengthExpr,
/// Height at the thick end, along Z.
pub height: LengthExpr,
/// The wedge's minimum corner. Default: the origin.
#[serde(default, deserialize_with = "present", skip_serializing_if = "Option::is_none")]
#[schemars(with = "PointExpr3")]
pub corner: Option<PointExpr3>,
/// How the wedge combines with existing bodies. Default: new.
#[serde(default)]
pub operation: Option<OperationParam>,
/// The bodies a join, cut or intersect works on. Default: every body in the feature's
/// component, not its sub-components (an intersect removes those the wedge doesn't meet).
/// Ignored when `participants` lists bodies.
#[serde(default)]
pub targets: Option<StringList>,
#[serde(flatten, deserialize_with = "feature_common")]
pub common: FeatureCommon,
}
}
- Descriptions are the documentation. The struct’s doc comment is the command’s summary and
each field’s doc comment is its description in the schema, so put nothing else on those
lines. Say the unit or form, the default (
Default: …) and when the key is ignored. Write for someone who has only the schema. Check what each value does where the feature is evaluated (crates/doc/src/eval.rsand the kernel), not only how the handler reads it: a limit the evaluation enforces (a torus’sminormust be less than itsmajor) or a key it ignores belongs in the description. - Required or optional follows the handler: a key it can’t do without is a plain field;
any other is
#[serde(default)] Option<T>. null: the common readers (str_,num,bool_,expr,string_list) readnullas absent, which a plain#[serde(default)] Option<T>matches. Readers that parsep.get(k)themselves (features::feature_sketch,features::profiles, the pick lists) refusenull; for those keys use#[serde(default, deserialize_with = "present", skip_serializing_if = "Option::is_none")]with#[schemars(with = "T")]. The key may then be absent but notnull, the schema has nonullbranch, andskip_serializing_ifstops schemars publishingdefault: null, which the schema itself would refuse.- Use a shared type for every value it fits (below) rather than
Stringorf64: shared types accept exactly what the handlers’ readers accept, give the handlers’ messages, and publish the matching schema.
4. Write the reply type
A feature command whose handler returns add_feature’s reply unchanged returns
FeatureResult (in schema/features.rs): use it, don’t copy it. A command that returns
anything else, including a feature command that reshapes the reply (solid.thread returns
{feature, name, thread}), gets its own reply type:
#![allow(unused)]
fn main() {
/// What `wedge.measure` returns.
#[derive(Debug, Serialize, JsonSchema)]
pub struct WedgeMeasureResult {
/// The wedge's volume, in mm³.
pub volume: f64,
/// In the warn parameter-check mode: what the call's parameters got wrong.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub param_warnings: Vec<String>,
}
}
- Build the handler’s reply from the type (
serde_json::to_value(reply)), asadd_feature_inbuildsFeatureResult, rather than ajson!literal next to it. Then the schema can’t drift from what the command returns. - Warn mode adds
param_warningsto a successful reply that is a JSON object, so an object reply type keeps that field. A reply that isn’t an object (a list) is returned unchanged and has no such field.
5. Register them
In the command file, next to its COMMANDS:
#![allow(unused)]
fn main() {
use crate::schema::features::FeatureResult;
use crate::schema::wedge::WedgeParams;
use crate::schema::{TypedContract, output_schema_for};
const WEDGE_CONTRACT: TypedContract<WedgeParams> = TypedContract::new();
// in COMMANDS:
CommandSpec::new("solid.wedge", "Wedge", crate::checked!("solid.wedge", prim_wedge))
.at("SOLID", "CREATE")
.icon("wedge")
.contract(&WEDGE_CONTRACT)
.output(output_schema_for::<FeatureResult>)
.params("length, width, height: expr; corner?: [x,y,z] …"),
}
.contract and .output publish the schemas, and checked! checks each call against the
contract that its id publishes, in front of the handler, so what is checked is always what is
published. The handler itself doesn’t change: it still reads the raw parameters. Keep the
.params(…) prose: the command list, the palette and describe_command’s params still show
it, until it is generated from the schemas.
6. Test it
In the module’s tests file:
schemas_and_the_key_check_agree: addassert_keys(input_schema_for::<WedgeParams>(), &[…])with the keys, sorted,FeatureCommon’s included. The keys then change only on purpose.strict_keeps_todays_errors: for calls the handler already refuses (a malformed value, an unknown spelling), add cases toassert_strict_keeps_todays_errors: strict must give the same message as the handler. Leave out the cases its documentation lists as different on purpose, such as a missing required key (strict says`length` is required, the handler something else).- A reply test: run the command and check the reply matches its output schema with
conforms(replies_match_the_output_schemainschema/features_tests.rsshows how; its misspelt-key call checks that a reply carrying warnings still matches). Run every form the command takes (each alternative key, such ascornerorcenter, eachoperation, one name and a list of names) in Warn, and check those replies have noparam_warnings: Warn runs a call the contract refuses anyway, so a matching reply doesn’t show the contract accepts the call. A reply that isn’t an object never carriesparam_warnings; run those calls in Strict, where a refused call is an error.
schema_defaults_are_valid checks the default of each top-level parameter on its own; a
nested type’s defaults are yours to check.
7. Converting a command that already exists
Do steps 1 to 6, remove its line from schema/unconverted.txt, then check the contract against
every caller the tests have: run the whole suite with the audit recorder and summarise it.
mkdir -p audit
SOLIDCRAFT_PARAM_AUDIT=$PWD/audit cargo test --workspace --no-fail-fast --features solidcraft-engine/param-audit
python3 scripts/param_audit.py audit
The recorder doesn’t create the directory, and it writes nothing if it can’t, so check the summary’s first line says at least one file. Each line under “SUCCEEDS TODAY, STRICT WOULD REFUSE” is a call that works today and that strict would refuse: a form the contract is missing (fix the contract) or a caller’s mistake, such as a misspelt key (fix the caller). Put the summary in the pull request.
Shared types
In crates/engine/src/schema/types.rs. Each accepts exactly what its reader accepts, most by
calling that reader.
| Type | For | Matches |
|---|---|---|
LengthExpr, AngleExpr | a number or an expression ("w / 2", "30 deg") | params::expr |
Point3, Point2 | [x, y, z] / [x, y] or {x, y, z} of numbers, in mm | params::vec3, params::vec2 |
PointExpr3 | a placement point whose coordinates may be expressions | features::point_param |
IdOrName | a feature by id or name (extrude’s sketch) | features::feature_sketch |
SketchRef | a sketch by id or name, with features::sketch_id’s message | features::sketch_id |
StringList | one name or a list of names | params::string_list |
OperationParam | new, join, cut, intersect and their spellings | Operation::parse |
FaceRef | a face by a point on it or its name | face::face_ref |
FaceRefs | the faces list of shell, draft, offset face, split face and face.delete (not solid.replace_face, whose messages differ) | features::face_points |
EdgeRef, EdgeRefs | an edge by a point on it or {body, index}; fillet’s and chamfer’s edges | features::edge_points |
ProfilesParam | which sketch profiles | features::profiles |
FeatureCommon | name, body_name, body_names, participants, flattened | features::add_feature |
When no shared type fits
Add one to types.rs, next to the reader it mirrors:
- Its
Deserializecalls the handler’s own reader, so “the contract accepts it” means “the handler accepts it”. Don’t re-implement the parsing. Many readers are private to their command file or need the design (features::point_paramtakes theSession). Split out the part that only looks at the JSON as apub(crate)function, have the handler call it, and re-export it fromcmd/mod.rs, asface_ref_formandedge_ref_formare; whether the value finds something in the design stays in the handler. - On failure it gives the handler’s message. Wrap a message that is already a whole
sentence, such as the handler’s
unknown operation `x` (…), inwhole(…), and the check passes it on as it is. Otherwise start it with “must”: the check names the key, so “must be a point [x, y, z] of numbers” becomes"`corner` must be a point [x, y, z] of numbers". (Any other message comes out as"`corner`: …".) - Its
JsonSchemadescribes the same values, with a description. Where JSON Schema can’t say something the reader checks (ignoring case, a byte length), say it in the description. - Test both: values the reader accepts and refuses are accepted and refused by the type, and
they match the schema (the
conformshelper inschema/tests.rs; seeface_and_edge_refs_match_the_pick_readers). - A handler that reads a key more leniently than any type can (a wrong-typed value read as if the key were absent) gets the stricter type: that difference is what warn and strict report.
Checklist
- The contract’s keys are exactly the handler’s reads, including
add_feature’s, the frame mapping’s andlive. - The struct and every field have descriptions, with defaults and when a key is ignored.
- Shared types wherever they fit; a new type calls the handler’s reader and gives its message.
FeatureResultfor a command that returnsadd_feature’s reply unchanged, else a reply type the handler builds its reply from.checked!,.contractand.outputon theCommandSpec, and the.paramsprose kept.assert_keys, error-message cases, a reply test.- For an existing command: its
unconverted.txtline removed and the audit summary in the PR.