Recipe module stable¶
Purpose & Scope¶
The Recipe module owns the abstract description of how to run an experiment, from the universal operations template down to the asset-bound plan that a Run will execute. Four aggregates form a ladder: Capability is the universal declarative template that says what a class of operation does; Method is the science-community technique class; Practice is the facility's curated adaptation; and Plan binds a Practice to specific Asset instances and wires their ports together. The ladder follows the ISA-88 General → Site → Master/Control Recipe progression, with Capability sitting above the ladder as the executor-agnostic template that both Method-shaped science recipes and Procedure-shaped operational ceremonies (in the Operation module) realize.
Recipe is the "what we plan to do" layer. The "what actually happened" layer lives in the Run module, which takes a Plan and runs it. Recipe knows nothing about scheduling, execution, or runtime parameter capture; it knows about declaration, versioning, deprecation, and the cross-aggregate validation that catches mismatches at bind time.
Out of scope
- Approval workflow. No
approve_plan/withdraw_planslices today. Whether a Plan is allowed to run is a question owned by the Decision module (with aRecipeApprovalcontext shape that lands when the first facility needs gated rollout). Recipe's lifecycle is purely declarative. - Per-Plan parameter overrides.
Plan.default_parametersis the operator's "what to use when nothing is overridden" baseline. The per-Run override and mergedeffective_parameterssnapshot live on the Run aggregate. - Calibration binding. Plans do not pin calibrations today; the Calibration module resolves the active revision at Run start. A
Plan.pinned_calibrationsfield lands when a facility needs reproducible runs against a frozen calibration set. - Capability code namespaces beyond
cora.capability.*. Facility-scoped extensions undercora.capability.<facility>.*are reserved but rejected today; the closed core opens when the first real cross-facility divergence demands it. - PaNET / EXPO trajectory facets. Capability carries an executor-agnostic parameter schema and a required-affordance set; richer scientific-taxonomy facets defer until a pilot consumer asks for them.
- Plan archiving and bulk export. Plans accumulate forever today. Cold-storage hooks land when the first deployment outgrows the projection's working set.
Aggregates¶
| Name | Identity | State summary | FSM |
|---|---|---|---|
Capability |
id: UUID |
id, code, name, status, version, description, required_affordances, executor_shapes, parameter_schema, replaced_by_capability_id |
yes (3-state) |
Method |
id: UUID |
id, name, capability_id, needed_families, needed_supplies, parameters_schema, status, version |
yes (3-state) |
Practice |
id: UUID |
id, name, method_id, site_id, status, version |
yes (3-state) |
Plan |
id: UUID |
id, name, practice_id, method_id, asset_ids, default_parameters, wires, status, version |
yes (3-state) |
Value Objects¶
| Name | Shape | Where used |
|---|---|---|
CapabilityCode |
trimmed string under namespace prefix cora.capability., 1-200 chars after prefix |
Capability.code |
CapabilityName |
trimmed string, 1-200 chars | Capability.name |
ExecutorShape |
closed StrEnum: Method | Procedure |
Capability.executor_shapes |
CapabilityStatus |
closed StrEnum: Defined | Versioned | Deprecated |
Capability.status |
MethodName |
trimmed string, 1-200 chars | Method.name |
MethodStatus |
closed StrEnum: Defined | Versioned | Deprecated |
Method.status |
PracticeName |
trimmed string, 1-200 chars | Practice.name |
PracticeStatus |
closed StrEnum: Defined | Versioned | Deprecated |
Practice.status |
PlanName |
trimmed string, 1-200 chars | Plan.name |
PlanStatus |
closed StrEnum: Defined | Versioned | Deprecated |
Plan.status |
Wire |
4-tuple (source_asset_id, source_port_name, target_asset_id, target_port_name); port names 1-100 chars after trim |
Plan.wires |
Version tags (version_tag) are operator-supplied free text, 1-50 chars after trim, validated at the API boundary and defensively in the decider; no value-object wrapper. Tags can be semver (v2.1.0), date-stamped (2026-Q3), or institution-specific.
The Affordance enum used in Capability.required_affordances is owned by the Equipment module and imported here; Capability's required-affordance set is the contract any implementer's bound Family.affordances must cover.
FSM¶
All four aggregates share the same three-state lifecycle. The genesis command differs; the version and deprecate transitions are identical.
stateDiagram-v2
[*] --> Defined: define_*
Defined --> Versioned: version_*
Versioned --> Versioned: version_*
Defined --> Deprecated: deprecate_*
Versioned --> Deprecated: deprecate_*
Deprecated --> [*]
| From | To | Command (per aggregate *) |
Event |
|---|---|---|---|
[*] |
Defined |
define_* |
*Defined |
Defined | Versioned |
Versioned |
version_* |
*Versioned |
Defined | Versioned |
Deprecated |
deprecate_* |
*Deprecated |
Deprecated is terminal: no command re-activates a deprecated declaration. Existing carriers that reference a deprecated upstream entry remain valid for read paths; downstream bind-time checks (see define_plan guards) reject new bindings against deprecated Practices or Methods. Re-deprecating an already-Deprecated aggregate raises (strict-not-idempotent). Re-versioning a Versioned aggregate with the same tag succeeds and emits a fresh event; re-attestation is a legitimate audit moment.
Schema and wiring updates (update_method_parameters_schema, update_plan_default_parameters, add_plan_wire, remove_plan_wire) are orthogonal to the lifecycle: they are permitted in Defined, Versioned, and Deprecated alike.
Events¶
Capability¶
| Event | Payload sketch | When emitted |
|---|---|---|
RecipeCapabilityDefined |
capability_id, code, name, required_affordances, executor_shapes, parameter_schema?, description?, occurred_at |
define_capability succeeds (genesis) |
RecipeCapabilityVersioned |
capability_id, version_tag, required_affordances, executor_shapes, parameter_schema?, description?, occurred_at |
version_capability succeeds; the full declarative contract replaces wholesale |
RecipeCapabilityDeprecated |
capability_id, replaced_by_capability_id?, occurred_at |
deprecate_capability succeeds; the optional pointer marks a successor |
Method¶
| Event | Payload sketch | When emitted |
|---|---|---|
MethodDefined |
method_id, name, capability_id, needed_families, needed_supplies, occurred_at |
define_method succeeds (genesis) |
MethodVersioned |
method_id, version_tag, occurred_at |
version_method succeeds |
MethodDeprecated |
method_id, occurred_at |
deprecate_method succeeds |
MethodParametersSchemaUpdated |
method_id, parameters_schema?, occurred_at |
update_method_parameters_schema succeeds; the schema replaces wholesale (None clears) |
Practice¶
| Event | Payload sketch | When emitted |
|---|---|---|
PracticeDefined |
practice_id, name, method_id, site_id, occurred_at |
define_practice succeeds (genesis) |
PracticeVersioned |
practice_id, version_tag, occurred_at |
version_practice succeeds |
PracticeDeprecated |
practice_id, occurred_at |
deprecate_practice succeeds |
Plan¶
| Event | Payload sketch | When emitted |
|---|---|---|
PlanDefined |
plan_id, name, practice_id, asset_ids, method_id, method_needed_families_snapshot, asset_families_snapshot, occurred_at |
define_plan succeeds (genesis); audit snapshots are payload-only, not folded into state |
PlanVersioned |
plan_id, version_tag, occurred_at |
version_plan succeeds |
PlanDeprecated |
plan_id, occurred_at |
deprecate_plan succeeds |
PlanDefaultParametersUpdated |
plan_id, default_parameters, occurred_at |
update_plan_default_parameters succeeds; the resolved post-merge dict is captured |
PlanWireAdded |
plan_id, source_asset_id, source_port_name, target_asset_id, target_port_name, occurred_at |
add_plan_wire succeeds |
PlanWireRemoved |
plan_id, source_asset_id, source_port_name, target_asset_id, target_port_name, occurred_at |
remove_plan_wire succeeds |
The PlanDefined audit snapshots pin what was checked at bind time (method_needed_families_snapshot, asset_families_snapshot) so the audit trail reproduces the validation even if Method or Asset state evolves later. The snapshots are payload-only; the evolver does not fold them into state.
Slices¶
| Command | Category | REST | MCP tool | Idempotency |
|---|---|---|---|---|
DefineCapability |
NEW | POST /capabilities |
define_capability |
required |
VersionCapability |
MODIFIED | POST /capabilities/{capability_id}/version |
version_capability |
none |
DeprecateCapability |
MODIFIED | POST /capabilities/{capability_id}/deprecate |
deprecate_capability |
none |
GetCapability |
QUERY | GET /capabilities/{capability_id} |
get_capability |
none |
DefineMethod |
NEW | POST /methods |
define_method |
required |
VersionMethod |
MODIFIED | POST /methods/{method_id}/version |
version_method |
none |
DeprecateMethod |
MODIFIED | POST /methods/{method_id}/deprecate |
deprecate_method |
none |
UpdateMethodParametersSchema |
MODIFIED | PUT /methods/{method_id}/parameters-schema |
update_method_parameters_schema |
none |
GetMethod |
QUERY | GET /methods/{method_id} |
get_method |
none |
ListMethods |
QUERY | GET /methods |
list_methods |
none |
DefinePractice |
NEW | POST /practices |
define_practice |
required |
VersionPractice |
MODIFIED | POST /practices/{practice_id}/version |
version_practice |
none |
DeprecatePractice |
MODIFIED | POST /practices/{practice_id}/deprecate |
deprecate_practice |
none |
GetPractice |
QUERY | GET /practices/{practice_id} |
get_practice |
none |
ListPractices |
QUERY | GET /practices |
list_practices |
none |
DefinePlan |
NEW | POST /plans |
define_plan |
required |
VersionPlan |
MODIFIED | POST /plans/{plan_id}/version |
version_plan |
none |
DeprecatePlan |
MODIFIED | POST /plans/{plan_id}/deprecate |
deprecate_plan |
none |
UpdatePlanDefaultParameters |
MODIFIED | PATCH /plans/{plan_id}/default-parameters |
update_plan_default_parameters |
none |
AddPlanWire |
MODIFIED | POST /plans/{plan_id}/wires |
add_plan_wire |
none |
RemovePlanWire |
MODIFIED | DELETE /plans/{plan_id}/wires |
remove_plan_wire |
none |
GetPlan |
QUERY | GET /plans/{plan_id} |
get_plan |
none |
ListPlans |
QUERY | GET /plans |
list_plans |
none |
define_plan is the only slice with cross-aggregate state validation in the decider. The handler pre-loads the Practice, the Method (via practice.method_id), and every bound Asset, then hands them to the pure decider as a PlanBindingContext. The decider rejects bindings against deprecated upstream entries, against decommissioned Assets, against family sets that do not cover the Method's needs, and against affordance sets that do not cover the bound Capability's contract.
update_plan_default_parameters accepts a JSON Merge Patch (RFC 7396); the slice merges against the current state, then the decider validates the merged result against the owning Method's parameters_schema. The event payload carries the resolved snapshot, not the patch.
add_plan_wire and remove_plan_wire enforce port-graph invariants in the decider: source ports must have direction=OUTPUT, target ports must have direction=INPUT, signal_type values must match exactly, both endpoint Assets must be in the Plan's bound set, both endpoint port names must exist on those Assets, target ports may receive at most one incoming wire (fan-in forbidden), and self-loops are allowed only between distinct ports on the same Asset.
Errors per slice. Beyond Pydantic boundary 422s, each slice raises:
DefineCapabilityInvalidCapabilityCodeError,InvalidCapabilityNameError,InvalidCapabilityDescriptionError,InvalidExecutorShapesError,CapabilityAlreadyExistsError,UnauthorizedVersionCapabilityCapabilityNotFoundError,CapabilityCannotVersionError,InvalidCapabilityVersionTagError,InvalidExecutorShapesError,UnauthorizedDeprecateCapabilityCapabilityNotFoundError,CapabilityCannotDeprecateError,UnauthorizedGetCapabilityCapabilityNotFoundErrorDefineMethodInvalidMethodNameError,InvalidMethodNeededSuppliesError,MethodAlreadyExistsError,CapabilityNotFoundError(handler-load),MethodParametersNotSubsetError(when the Method's schema does not subset the bound Capability's contract),UnauthorizedVersionMethodMethodNotFoundError,MethodCannotVersionError,InvalidMethodVersionTagError,UnauthorizedDeprecateMethodMethodNotFoundError,MethodCannotDeprecateError,UnauthorizedUpdateMethodParametersSchemaMethodNotFoundError,InvalidMethodParametersSchemaError,MethodParametersNotSubsetError,UnauthorizedGetMethod,ListMethodsMethodNotFoundError(Get only); boundary 422 only otherwiseDefinePracticeInvalidPracticeNameError,PracticeAlreadyExistsError,UnauthorizedVersionPracticePracticeNotFoundError,PracticeCannotVersionError,InvalidPracticeVersionTagError,UnauthorizedDeprecatePracticePracticeNotFoundError,PracticeCannotDeprecateError,UnauthorizedGetPractice,ListPracticesPracticeNotFoundError(Get only); boundary 422 only otherwiseDefinePlanInvalidPlanNameError,InvalidPlanError(emptyasset_ids),PlanAlreadyExistsError,PracticeNotFoundError,MethodNotFoundError,AssetNotFoundError,PracticeDeprecatedError,MethodDeprecatedError,AssetDecommissionedError,PlanCapabilitiesNotSatisfiedError,PlanAffordancesNotSatisfiedError,UnauthorizedVersionPlanPlanNotFoundError,PlanCannotVersionError,InvalidPlanVersionTagError,UnauthorizedDeprecatePlanPlanNotFoundError,PlanCannotDeprecateError,UnauthorizedUpdatePlanDefaultParametersPlanNotFoundError,InvalidPlanDefaultParametersError,UnauthorizedAddPlanWirePlanNotFoundError,InvalidWireError,PlanWireAlreadyExistsError,PlanWireAssetNotBoundError,PlanWirePortNotFoundError,PlanWireDirectionMismatchError,PlanWireSignalTypeMismatchError,PlanWireTargetAlreadyConnectedError,PlanWireSelfLoopError,UnauthorizedRemovePlanWirePlanNotFoundError,InvalidWireError,PlanWireNotFoundError,UnauthorizedGetPlan,ListPlansPlanNotFoundError(Get only); boundary 422 only otherwise
Storage & Projections¶
Four read-side tables back the Recipe module, one per aggregate. All four follow the same lifecycle-summary shape: a single row per aggregate, mutated by ON CONFLICT upserts as the lifecycle events arrive.
CREATE TABLE proj_recipe_capability_summary (
capability_id UUID PRIMARY KEY,
code TEXT NOT NULL,
name TEXT NOT NULL,
status TEXT NOT NULL
CHECK (status IN ('Defined', 'Versioned', 'Deprecated')),
version_tag TEXT,
description TEXT,
required_affordances TEXT[] NOT NULL DEFAULT ARRAY[]::TEXT[],
executor_shapes TEXT[] NOT NULL DEFAULT ARRAY[]::TEXT[],
parameter_schema_present BOOLEAN NOT NULL DEFAULT FALSE,
replaced_by_capability_id UUID,
created_at TIMESTAMPTZ NOT NULL,
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX proj_recipe_capability_summary_keyset_idx
ON proj_recipe_capability_summary (created_at, capability_id);
CREATE UNIQUE INDEX proj_recipe_capability_summary_code_idx
ON proj_recipe_capability_summary (code);
CREATE TABLE proj_recipe_method_summary (
method_id UUID PRIMARY KEY,
name TEXT NOT NULL,
status TEXT NOT NULL
CHECK (status IN ('Defined', 'Versioned', 'Deprecated')),
version_tag TEXT,
created_at TIMESTAMPTZ NOT NULL,
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX proj_recipe_method_summary_keyset_idx
ON proj_recipe_method_summary (created_at, method_id);
CREATE TABLE proj_recipe_practice_summary (
practice_id UUID PRIMARY KEY,
name TEXT NOT NULL,
method_id UUID NOT NULL,
site_id UUID NOT NULL,
status TEXT NOT NULL
CHECK (status IN ('Defined', 'Versioned', 'Deprecated')),
version_tag TEXT,
created_at TIMESTAMPTZ NOT NULL,
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX proj_recipe_practice_summary_keyset_idx
ON proj_recipe_practice_summary (created_at, practice_id);
CREATE INDEX proj_recipe_practice_summary_method_idx
ON proj_recipe_practice_summary (method_id);
CREATE TABLE proj_recipe_plan_summary (
plan_id UUID PRIMARY KEY,
name TEXT NOT NULL,
practice_id UUID NOT NULL,
method_id UUID NOT NULL,
status TEXT NOT NULL
CHECK (status IN ('Defined', 'Versioned', 'Deprecated')),
version_tag TEXT,
created_at TIMESTAMPTZ NOT NULL,
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX proj_recipe_plan_summary_keyset_idx
ON proj_recipe_plan_summary (created_at, plan_id);
CREATE INDEX proj_recipe_plan_summary_practice_idx
ON proj_recipe_plan_summary (practice_id);
GET /{aggregate}/{id} for Method, Practice, and Plan folds the event stream so the response reflects the latest committed write without projection lag. GET /capabilities/{id} does the same. The four list_* slices (and get_capability's list variants when they land) read from the projections with keyset pagination over (created_at, {aggregate}_id).
The Capability summary carries required_affordances and executor_shapes as TEXT[] so a future filter on "list capabilities affording X" is an index add, not a column add. parameter_schema_present is a boolean; the schema content itself stays in the event stream to keep the summary row small. The Plan summary intentionally omits asset_ids (a multi-valued binding); a future proj_recipe_plan_assets join table will surface "all plans using Asset X" when use cases demand it. Plan.default_parameters and Plan.wires also stay out of the summary; both fold from the event stream on single-Plan reads.
Cross-Module boundaries¶
| Module | Relationship | What's exchanged |
|---|---|---|
| Equipment | depends-on | Method.needed_families references Family.id values; Plan.asset_ids references Asset.id values; Plan.wires reference Asset.ports declared on bound Assets; Capability.required_affordances uses the Affordance enum owned by Equipment |
| Operation | shared-enum-with | Capability.executor_shapes lists Procedure as a valid implementer; Procedure.capability_id (Operation BC) points back at a Capability declared here |
| Supply | depends-on-kind | Method.needed_supplies references Supply.kind strings (instance-aggregate vs type-aggregate asymmetry, since kinds are facility-portable and instance UUIDs are not) |
| Run | upstream-of | Run.plan_id references a Plan; the Method's parameters_schema is the validation contract for Run parameter overrides |
| Calibration | upstream-of | Run.pinned_calibrations (resolved at Run start) is keyed by Asset and Capability/quantity tuples that originate in the Recipe ladder |
| Decision | shared-id-with | Decision.subject_id may point at a Plan when the decision relates to recipe approval or rollback (advisory today) |
| Trust | gated-by | every Recipe slice is gated by an Authorize check; new Plan-binding may carry a Zone scope when a facility wires policy against beamline ownership |
| Access | shared-id-with | every event carries actor_id on the envelope; the originating principal is an Actor |
Cross-aggregate references inside Recipe (Practice → Method, Plan → Practice, Plan → Asset) and cross-module references (Method → Family, Method → Supply kind) follow the eventual-consistency stance: deciders do not verify the reference exists at write time. Mismatch surfaces at Plan binding, where the define_plan handler pre-loads the entire dependency graph and the decider rejects structurally invalid bindings.
Examples¶
The four examples below cover a typical declaration walk: a universal Capability, a Method that realizes it, a Plan that binds the Method (via a Practice) to specific Assets, and a wire added to the Plan to connect two ports. Practice definition follows the same shape as Method and is omitted for brevity. For the REST/MCP equivalence, auth, and idempotency conventions these examples share, see Reading the examples on the Modules landing page.
Define a Capability¶
POST /capabilities
Content-Type: application/json
Idempotency-Key: 7e2c1a4b-9f3d-4a2c-8b1e-5d4f3a2b1c0d
X-Principal-Id: 11111111-2222-3333-4444-555555555555
{
"code": "cora.capability.continuous_rotation_sweep",
"name": "Continuous Rotation Sweep",
"description": "Sample rotates continuously while the camera streams projections at fixed angular spacing.",
"required_affordances": ["rotates", "captures_images"],
"executor_shapes": ["Method"],
"parameter_schema": {
"type": "object",
"properties": {
"exposure_ms": {
"type": "number", "minimum": 0,
"unit": {"system": "udunits", "code": "ms"}
},
"rotation_speed": {
"type": "number", "minimum": 0,
"unit": {"system": "udunits", "code": "deg/s"}
}
},
"required": ["exposure_ms", "rotation_speed"]
}
}
Returns 201 Created with the newly-assigned capability_id. The code must start with cora.capability. and carry a non-empty suffix; executor_shapes must be a non-empty subset of {Method, Procedure}.
mcp.call_tool(
"define_capability",
{
"code": "cora.capability.continuous_rotation_sweep",
"name": "Continuous Rotation Sweep",
"description": "Sample rotates continuously while the camera streams projections.",
"required_affordances": ["rotates", "captures_images"],
"executor_shapes": ["Method"],
"parameter_schema": {...},
},
)
Define a Method realizing a Capability¶
POST /methods
Content-Type: application/json
Idempotency-Key: 9a8b7c6d-5e4f-3a2b-1c0d-9e8f7a6b5c4d
X-Principal-Id: 11111111-2222-3333-4444-555555555555
{
"name": "Fly-Scan Tomography",
"capability_id": "<capability-id>",
"needed_families": ["<rotary-stage-family-id>", "<camera-family-id>"],
"needed_supplies": ["liquid_nitrogen"],
"parameters_schema": {
"type": "object",
"properties": {
"exposure_ms": {
"type": "number", "minimum": 1, "maximum": 1000,
"unit": {"system": "udunits", "code": "ms"}
},
"rotation_speed": {
"type": "number", "minimum": 0.1, "maximum": 30.0,
"unit": {"system": "udunits", "code": "deg/s"}
}
},
"required": ["exposure_ms", "rotation_speed"]
}
}
Returns 201 Created. The supplied parameters_schema must validate as a subset of the bound Capability's parameter_schema; widening the contract (introducing a property the Capability does not declare, widening a bound, dropping a Capability-required field) returns 409 Conflict with MethodParametersNotSubsetError.
Bind a Plan to a Practice and Assets¶
POST /plans
Content-Type: application/json
Idempotency-Key: 1f2e3d4c-5b6a-7980-1a2b-3c4d5e6f7a8b
X-Principal-Id: 11111111-2222-3333-4444-555555555555
{
"name": "35-BM fly-scan tomography, 2026 spring run",
"practice_id": "<practice-id>",
"asset_ids": [
"<aerotech-rotary-stage-id>",
"<flir-camera-id>"
]
}
Returns 201 Created with the assigned plan_id. The decider pre-loads the Practice and Method (rejects if either is Deprecated), every bound Asset (rejects if any is Decommissioned), and computes the union of each Asset's families; if that union does not cover the Method's needed_families the response is 409 Conflict with PlanCapabilitiesNotSatisfiedError and the missing family ids. The same check runs for Capability.required_affordances against the union of bound Assets' Family.affordances.
Wire two bound Assets in a Plan¶
POST /plans/<plan-id>/wires
Content-Type: application/json
X-Principal-Id: 11111111-2222-3333-4444-555555555555
{
"source_asset_id": "<pandabox-id>",
"source_port_name": "trigger_out",
"target_asset_id": "<flir-camera-id>",
"target_port_name": "trigger_in"
}
Returns 201 Created. The decider checks that both endpoint Assets are in the Plan's asset_ids, both endpoint ports exist on those Assets, source has direction=OUTPUT and target has direction=INPUT, the two ports' signal_type values match exactly, and the target port is not already the destination of another wire (fan-in forbidden). DELETE /plans/<plan-id>/wires with the same 4-tuple body removes a wire; both add and remove are strict-not-idempotent and reject duplicate or missing wires with 409.