Recipe module stable¶
Purpose & Scope¶
The Recipe module owns the abstract declaration of how to run an experiment, from the universal operations template down to the asset-bound Plan that a Run will execute. Enacting that declaration belongs to the execution edge outside this module: the Operation module's Conductor walks the expanded steps, and the Run module drives the Plan-bound batch. Five aggregates carry the responsibility. Capability is the universal declarative template that says what a class of operation does, and it sits above the recipe ladder rather than on it. Three more are this BC's rungs of that ladder, narrowing toward execution: Method is the science-community technique class (the ISA-88 general recipe), Practice is the facility's curated adaptation (site recipe), and Plan binds a Practice to specific Asset instances and wires their ports together (master recipe). The ladder's executing bottom rung, Run (control recipe), lives in the Run module; both Method-shaped science recipes and Procedure-shaped operational ceremonies (in the Operation module) realize the Capability above it.
The fifth aggregate, Recipe, is the executable body: a deployment-bound, ordered tuple of templated steps that references one Capability by capability_id and, once an operator supplies parameter bindings, expands into the flat step list the Operation Conductor walks. Recipe sits beside Capability (which declares the contract) rather than inside the bind ladder; it iterates faster than Capability and carries the body, while Method stays the technique-class contract and Plan stays the Asset-bound binding.
Capability sits on top as the executor-agnostic template; the ladder descends from it to execution, and the shapes that realize it branch off (dashed = optional; Run and Procedure belong to neighbouring modules):
flowchart TD
C[Capability<br/>universal template]
C -- realized by --> M[Method<br/>technique class]
C -- realized by --> Re[Recipe<br/>executable steps]
C -. realized by (optional) .-> Pr[Procedure<br/>Operation module]
M -- adapted by --> Pra[Practice<br/>facility curation]
Pra -- bound by --> Pl[Plan<br/>Asset-wired]
Pl -- executed as --> Run[Run<br/>Run module]
Re -- expands into --> Pr
Pr -. phase of: parent_run_id .-> Run
The Recipe module is the "what we plan to do" layer, along two axes: the abstract-to-bound ladder plans which equipment a Run uses (Plan is its equipment-bound rung), and the Recipe aggregate plans the step-by-step body a Procedure conducts. The "what actually happened" layer lives in the Run module, which takes a Plan and runs it, and in the Operation module, which conducts a Procedure. The Recipe module itself 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.
To make the two tracks concrete, here is a simple tomography session placed on the diagram: the acts that leave data on the Run side, the prep that readies the instrument on the Procedure side.
The scan itself is a Run (science track), Capability down to Run:
| Rung | What it is, for this scan |
|---|---|
Capability |
cora.capability.tomography: the contract. Provides tomography, requires a Detector and a Positioner, and its parameters include the projection count and the exposure. |
Method |
tomography: the portable technique. Step-scan projections over a rotation, written against the Detector and Positioner roles, naming no hardware. |
Practice |
a facility's curated adaptation of that Method, carrying the local know-how that makes it operable. |
Plan |
binds the Practice to that facility's actual detector and positioner Assets, wires their ports, and sets default parameter values. |
Run |
one operator-started execution of the Plan, one afternoon against one sample, producing a Dataset and recording what actually happened. |
The prep that readies the instrument is the operational track (Procedure): homing the motors and recovering a stuck controller (hexapod_reboot) are state changes that leave no Dataset, and aligning the rotation center leaves a rotation_center Calibration, not a Dataset (a measured calibration can only be sourced from a Procedure). Each conducts Setpoint / Action / Check steps and records a step log. A Procedure may also run as a phase of a Run (parent_run_id, an alignment performed mid-scan).
The split is data-of-record vs not, not read-versus-actuate. Aligning rotates the stage and reads frames (heavy actuation and reads) yet is a Procedure, because its output of record is the rotation_center value and the frames are transient working data; a reconstruction does no actuation at all yet is a Run, because it leaves a reconstructed Dataset. A separate, orthogonal axis decides who drives the act: CORA's conducting engine over the relevant port (control over ControlPort, compute over ComputePort), or a facility tool that CORA only records (at 2-BM, TomoScan owns the scan loop). See the Run vs Procedure boundary rule.
One question decides the track: does the act leave a Dataset of record? Yes (acquired or computed) -> Run; no (a calibration value or only a state change) -> Procedure. Dark and flat baselines answer yes like any other capture: they leave a baseline Dataset, so they are Runs, with any conducting Procedure carried as a phase of the Run via parent_run_id. They carry no subject_id, and that makes no difference to the track.
One Capability can be realized more than one way: cora.capability.energy_change is realized by the portable beamline_energy_change Method and by a deployment-bound energy-change Recipe. Every realizer points up to the one shared Capability, which is why the diagram fans out as a tree from Capability rather than closing into a cycle.
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_calibration_idsfield 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, parameters_schema, replaced_by_capability_id, suggested_role_ids |
yes (3-state) |
Method |
id: UUID |
id, name, capability_id, needed_family_ids, needed_assembly_ids, needed_supplies, required_roles, parameters_schema, launch_spec, status, version, content_hash |
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, role_bindings, status, version |
yes (3-state) |
Recipe |
id: UUID |
id, name, capability_id, steps: tuple[RecipeStep], status, version, replaced_by_recipe_id? |
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 |
RecipeName |
trimmed string, 1-200 chars | Recipe.name |
RecipeStatus |
closed StrEnum: Defined | Versioned | Deprecated |
Recipe.status |
RecipeStep |
closed union: RecipeSetpointStep | RecipeActionStep | RecipeCheckStep; each carries templated fields with BindingRef sentinels resolved at expansion |
members of Recipe.steps (non-empty) |
Wire |
4-tuple (source_asset_id, source_port_name, target_asset_id, target_port_name); port names 1-100 chars after trim |
Plan.wires |
RoleName |
trimmed string, 1-50 chars; Method-local positional role label | RoleRequirement.role_name, RoleBinding.role_name |
PortRequirement |
3-tuple (port_name, direction, signal_type); direction is the Equipment PortDirection enum; port name + signal type bounds mirror Asset.ports |
RoleRequirement.required_ports |
RoleRequirement |
tuple (role_name, role_kind, family_id, required_ports, optional); role_kind: UUID \| None targets a global Role contract, family_id: UUID \| None is the anatomical escape hatch, with an XOR invariant (exactly one set) |
Method.required_roles |
RoleBinding |
tuple (role_name, asset_id); pins which Asset fills a declared role slot |
Plan.role_bindings |
ArgStyle |
closed StrEnum: value | flag_only |
LaunchArg.style |
LaunchArg |
tuple (name, flag, position, required, style); name names a parameters_schema key, never an interpolation string; flag XOR position selects flag-style vs positional |
members of LaunchSpec.args |
LaunchSpec |
tuple (base_command, args, input_arg, output_arg); the vetted recipe a conduct caller selects instead of posting raw argv |
Method.launch_spec |
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.
RoleRequirement carries either role_kind (the federation-portable path: any Asset whose bound Family advertises role_kind in its presents_as and whose affordances superset the Role's required_affordances satisfies it) or family_id (the slice-1 anatomical escape hatch: direct family-id membership). The XOR is enforced in the RoleRequirement.__post_init__ (both-set raises RoleRequirementBindingDuplicateError, neither-set raises InvalidRoleRequirementTargetError) and again at the wire layer. The MethodRequiredRoleAdded payload evolved additively to carry role_kind (no new event class); Method.content_subset renders role_kind only when non-None so pre-role_kind Methods keep their content_hash byte-stable.
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 five 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.
A Method may also carry an optional launch_spec (update_method_launch_spec): the vetted recipe for a compute conduct. Where parameters_schema says which knobs a Run may set, launch_spec says how those knobs become a command line. Each LaunchArg names a parameters_schema key (never an interpolation string), so a conduct caller picks the Method and supplies validated values rather than posting raw argv; the server renders the argv list itself and runs it shell-free, which makes command injection a type fact rather than a runtime hope. Two cross-checks keep the pair coherent: update_method_launch_spec rejects an arg that names a key the schema does not declare (and requires a boolean-typed key for a flag_only arg), and update_method_parameters_schema rejects a schema change that would drop a key a stored launch_spec still binds. launch_spec participates in content_hash in argv order (it is the one content member whose element order is load-bearing). The conduct endpoint is dual-mode during migration: a Method with a launch_spec always builds its argv server-side and refuses a raw command, while a Method without one still accepts a raw command, gated by CORA_ALLOW_RAW_CONDUCT.
Events¶
Capability¶
| Event | Payload sketch | When emitted |
|---|---|---|
CapabilityDefined |
capability_id, code, name, required_affordances, executor_shapes, parameters_schema?, description?, occurred_at |
define_capability succeeds (genesis) |
CapabilityVersioned |
capability_id, version_tag, required_affordances, executor_shapes, parameters_schema?, description?, occurred_at |
version_capability succeeds; the full declarative contract replaces wholesale |
CapabilityDeprecated |
capability_id, replaced_by_capability_id?, occurred_at |
deprecate_capability succeeds; the optional pointer marks a successor |
CapabilitySuggestedRolesUpdated |
capability_id, suggested_role_ids, occurred_at |
update_capability_suggested_roles succeeds; the editorial set replaces wholesale (orthogonal to the lifecycle) |
Method¶
| Event | Payload sketch | When emitted |
|---|---|---|
MethodDefined |
method_id, name, capability_id, needed_family_ids, 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) |
MethodLaunchSpecUpdated |
method_id, launch_spec?, occurred_at |
update_method_launch_spec succeeds; the launch spec replaces wholesale (None clears) |
MethodRequiredRoleAdded |
method_id, role_name, family_id?, role_kind?, required_ports, optional, occurred_at |
add_method_required_role succeeds; family_id XOR role_kind per the RoleRequirement invariant |
MethodRequiredRoleRemoved |
method_id, role_name, occurred_at |
remove_method_required_role succeeds |
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_family_ids_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 |
PlanRoleBound |
plan_id, role_name, asset_id, occurred_at |
bind_plan_role succeeds; pins which Asset fills a declared role slot |
PlanRoleUnbound |
plan_id, role_name, occurred_at |
unbind_plan_role succeeds; the bound asset_id is reconstructable at fold |
The PlanDefined audit snapshots pin what was checked at bind time (method_needed_family_ids_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.
Recipe¶
| Event | Payload sketch | When emitted |
|---|---|---|
RecipeDefined |
recipe_id, name, capability_id, steps, occurred_at |
define_recipe succeeds (genesis) |
RecipeVersioned |
recipe_id, version_tag, steps, occurred_at |
version_recipe succeeds; name and capability_id are preserved (not in payload), steps replace wholesale |
RecipeDeprecated |
recipe_id, replaced_by_recipe_id?, occurred_at |
deprecate_recipe succeeds; the optional pointer marks a successor |
Recipe.capability_id is required and immutable across versions; a Recipe is identified by capability_id + name.
Slices¶
| Command / query | REST | MCP tool |
|---|---|---|
AddMethodRequiredRole |
POST /methods/{method_id}/add-required-role |
add_method_required_role |
AddPlanWire |
POST /plans/{plan_id}/add-wire |
add_plan_wire |
BindPlanRole |
POST /plans/{plan_id}/bind-role |
bind_plan_role |
DefineCapability |
POST /capabilities |
define_capability |
DefineMethod |
POST /methods |
define_method |
DefinePlan |
POST /plans |
define_plan |
DefinePractice |
POST /practices |
define_practice |
DefineRecipe |
POST /recipes |
define_recipe |
DeprecateCapability |
POST /capabilities/{capability_id}/deprecate |
deprecate_capability |
DeprecateMethod |
POST /methods/{method_id}/deprecate |
deprecate_method |
DeprecatePlan |
POST /plans/{plan_id}/deprecate |
deprecate_plan |
DeprecatePractice |
POST /practices/{practice_id}/deprecate |
deprecate_practice |
DeprecateRecipe |
POST /recipes/{recipe_id}/deprecate |
deprecate_recipe |
GetCapability |
GET /capabilities/{capability_id} |
get_capability |
GetMethod |
GET /methods/{method_id} |
get_method |
GetPlan |
GET /plans/{plan_id} |
get_plan |
GetPractice |
GET /practices/{practice_id} |
get_practice |
GetRecipe |
GET /recipes/{recipe_id} |
get_recipe |
InspectPlanBinding |
POST /plans/inspect-binding |
inspect_plan_binding |
ListMethods |
GET /methods |
list_methods |
ListPlans |
GET /plans |
list_plans |
ListPractices |
GET /practices |
list_practices |
RemoveMethodRequiredRole |
POST /methods/{method_id}/remove-required-role |
remove_method_required_role |
RemovePlanWire |
POST /plans/{plan_id}/remove-wire |
remove_plan_wire |
UnbindPlanRole |
POST /plans/{plan_id}/unbind-role |
unbind_plan_role |
UpdateCapabilitySuggestedRoles |
POST /capabilities/{capability_id}/suggested-roles |
update_capability_suggested_roles |
UpdateMethodLaunchSpec |
POST /methods/{method_id}/launch-spec |
update_method_launch_spec |
UpdateMethodParametersSchema |
POST /methods/{method_id}/parameters-schema |
update_method_parameters_schema |
UpdatePlanDefaultParameters |
PATCH /plans/{plan_id}/default-parameters |
update_plan_default_parameters |
VersionCapability |
POST /capabilities/{capability_id}/version |
version_capability |
VersionMethod |
POST /methods/{method_id}/version |
version_method |
VersionPlan |
POST /plans/{plan_id}/version |
version_plan |
VersionPractice |
POST /practices/{practice_id}/version |
version_practice |
VersionRecipe |
POST /recipes/{recipe_id}/version |
version_recipe |
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.
inspect_plan_binding is the read-only preview-before-define companion to define_plan. It runs the same Practice -> Method -> Capability -> per-Asset -> per-Family load fan-out without emitting events, then returns a diagnostic with the wired Assets' conditions, their contributed affordances, any missing families or affordances, and (when a pool is configured) other facility Assets whose Families could cover each missing affordance. Operators use it to see condition (Nominal / Degraded / Faulted) and lifecycle (Commissioned / Decommissioned / Maintenance) of candidate Assets before committing the bind. The candidate enumeration is projection-backed: it cross-reads Equipment's proj_equipment_asset_family_membership join projection via the cora.equipment.aggregates.family.read aggregate surface (list_family_ids + list_asset_ids_in_families). In in-memory test mode (no pool) the candidate lookup is skipped gracefully and the field is empty.
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.
bind_plan_role pins which Asset fills a RoleRequirement declared on the Plan's Method. The satisfaction check bifurcates on the matching RoleRequirement's XOR target. The family_id path is the existing direct-membership check (role.family_id in asset.family_ids). The role_kind path is an ANY-single-family disjunction: the handler edge-loads the Role via RoleLookup plus a FamilyLookup batch over the Asset's family_ids (and, when the Asset is part of a Fixture, the materialized Assembly via AssemblyLookup), then the decider accepts the binding iff at least one bound Family advertises role_kind in its presents_as and carries affordances superset of the Role's required_affordances, or the composed Assembly's presents_as contains role_kind. A Role contract id that does not resolve at the handler edge surfaces as RoleNotFoundError (404). Port-coverage and wire-graph consistency checks run after satisfaction, unchanged.
update_capability_suggested_roles authors the editorial Capability.suggested_role_ids set wholesale (the body key is suggested_role_ids, an empty list clears it). The field is documentation-only: it records which global Role contracts an operator suggests the Capability is naturally satisfied by, and no fitness test or decider gates Method authoring against it. The handler edge-loads every role_id via RoleLookup (parallel batch) so an unresolved id returns RoleNotFoundError (404) rather than recording a dangling suggestion. The slice is restricted to Defined and Versioned Capabilities; a Deprecated Capability rejects with CapabilityCannotUpdateSuggestedRolesError.
Errors per slice. Beyond Pydantic boundary 422s, each slice raises:
DefineCapabilityInvalidCapabilityCodeError,InvalidCapabilityNameError,InvalidCapabilityDescriptionError,InvalidExecutorShapesError,CapabilityAlreadyExistsError,UnauthorizedVersionCapabilityCapabilityNotFoundError,CapabilityCannotVersionError,InvalidCapabilityVersionTagError,InvalidExecutorShapesError,UnauthorizedDeprecateCapabilityCapabilityNotFoundError,CapabilityCannotDeprecateError,UnauthorizedUpdateCapabilitySuggestedRolesCapabilityNotFoundError,CapabilityCannotUpdateSuggestedRolesError(Deprecated rejects),RoleNotFoundError(any supplied role_id fails the handler-sideRoleLookup),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,UnauthorizedAddMethodRequiredRole/RemoveMethodRequiredRoleMethodNotFoundError,MethodCannotMutateRequiredRolesError(Method notDefined),MethodRoleNameAlreadyDeclaredError(add) /MethodRoleNameNotFoundError(remove),RoleRequirementBindingDuplicateError/InvalidRoleRequirementTargetError(thefamily_idXORrole_kindinvariant),UnauthorizedGetMethod,ListMethodsMethodNotFoundError(Get only); boundary 422 only otherwiseDefinePracticeInvalidPracticeNameError,PracticeAlreadyExistsError,UnauthorizedVersionPracticePracticeNotFoundError,PracticeCannotVersionError,InvalidPracticeVersionTagError,UnauthorizedDeprecatePracticePracticeNotFoundError,PracticeCannotDeprecateError,UnauthorizedGetPractice,ListPracticesPracticeNotFoundError(Get only); boundary 422 only otherwiseDefinePlanInvalidPlanNameError,PlanAssetsRequiredError(emptyasset_ids),PlanAlreadyExistsError,PracticeNotFoundError,MethodNotFoundError,AssetNotFoundError,PlanBoundPracticeDeprecatedError,PlanBoundMethodDeprecatedError,PlanAssetDecommissionedError,PlanFamiliesNotSatisfiedError,PlanAffordancesNotSatisfiedError,UnauthorizedVersionPlanPlanNotFoundError,PlanCannotVersionError,InvalidPlanVersionTagError,UnauthorizedDeprecatePlanPlanNotFoundError,PlanCannotDeprecateError,UnauthorizedUpdatePlanDefaultParametersPlanNotFoundError,InvalidPlanDefaultParametersError,UnauthorizedAddPlanWirePlanNotFoundError,InvalidWireError,PlanWireAlreadyExistsError,PlanWireAssetNotBoundError,PlanWirePortNotFoundError,PlanWireDirectionMismatchError,PlanWireSignalTypeMismatchError,PlanWireTargetAlreadyConnectedError,PlanWireSelfLoopError,UnauthorizedRemovePlanWirePlanNotFoundError,InvalidWireError,PlanWireNotFoundError,UnauthorizedBindPlanRolePlanNotFoundError,PlanCannotMutateRoleBindingsError(Plan notDefined),PlanRoleAssetNotBoundError,PlanRoleAlreadyBoundError,MethodNotFoundError,PlanRoleNameNotDeclaredError,AssetNotFoundError,RoleNotFoundError(role_kindpath edge-load),PlanRoleFamilyMismatchError(family_idpath),PlanRoleFamilyNotResolvableError(role_kindpath; an Asset Family does not resolve viaFamilyLookup),PlanRoleAssetCannotPresentError(role_kindpath; no Family or composed Assembly presents the Role with covering affordances),PlanRolePortCoverageNotSatisfiedError,PlanWireRoleEndpointMismatchError,UnauthorizedUnbindPlanRolePlanNotFoundError,PlanCannotMutateRoleBindingsError(Plan notDefined),PlanRoleNotBoundError(strict-not-idempotent),UnauthorizedGetPlan,ListPlansPlanNotFoundError(Get only); boundary 422 only otherwiseInspectPlanBindingPracticeNotFoundError,MethodNotFoundError,AssetNotFoundError,CapabilityNotFoundError,FamilyNotFoundError,Unauthorized(same NotFound set asDefinePlansince both share the load fan-out)DefineRecipeRecipeAlreadyExistsError,InvalidRecipeNameError,EmptyRecipeStepsError,CapabilityNotFoundError(handler-load),RecipeRequiresCapabilityParametersSchemaError,RecipeBindingReferencesUnknownParameterError(a stepBindingRefnames a parameter the Capability schema does not declare),UnauthorizedVersionRecipeRecipeNotFoundError,InvalidRecipeVersionTagError,EmptyRecipeStepsError,RecipeCannotVersionError(Deprecated), the same binding-validation errors asDefineRecipe,UnauthorizedDeprecateRecipeRecipeNotFoundError,RecipeCannotDeprecateError,UnauthorizedGetRecipeRecipeNotFoundError
Storage & Projections¶
Five read-side tables back the Recipe module, one per aggregate. All five 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[],
parameters_schema_present BOOLEAN NOT NULL DEFAULT FALSE,
replaced_by_capability_id UUID,
suggested_role_ids UUID[] NOT NULL DEFAULT ARRAY[]::UUID[],
versioned_at TIMESTAMPTZ,
deprecated_at TIMESTAMPTZ,
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 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,
parameters_schema_present BOOLEAN NOT NULL DEFAULT FALSE,
versioned_at TIMESTAMPTZ,
deprecated_at TIMESTAMPTZ,
content_hash 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,
versioned_at TIMESTAMPTZ,
deprecated_at TIMESTAMPTZ,
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,
default_parameters_present BOOLEAN NOT NULL DEFAULT FALSE,
versioned_at TIMESTAMPTZ,
deprecated_at TIMESTAMPTZ,
content_hash 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);
CREATE TABLE proj_recipe_recipe_summary (
recipe_id UUID PRIMARY KEY,
name TEXT NOT NULL,
capability_id UUID NOT NULL,
status TEXT NOT NULL
CHECK (status IN ('Defined', 'Versioned', 'Deprecated')),
version_tag TEXT,
steps_count INTEGER NOT NULL DEFAULT 0,
replaced_by_recipe_id UUID,
versioned_at TIMESTAMPTZ,
deprecated_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL,
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX proj_recipe_recipe_summary_keyset_idx
ON proj_recipe_recipe_summary (created_at, recipe_id);
CREATE INDEX proj_recipe_recipe_summary_capability_idx
ON proj_recipe_recipe_summary (capability_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. parameters_schema_present is a boolean; the schema content itself stays in the event stream to keep the summary row small. suggested_role_ids is a UUID[] maintained by CapabilitySuggestedRolesUpdated wholesale-replace; it is documentation-only (no fitness check binds Methods to it) and defaults to the empty array for every pre-existing row. 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.
All five summaries carry versioned_at + deprecated_at lifecycle timestamps (Path C: state stays minimal, projection holds the timeline). Method and Plan also carry content_hash for content-addressed identity lookup. The Capability summary previously had a UNIQUE index on code for write-time collision detection; that constraint was dropped (the decider already enforces code uniqueness against the aggregate stream, and the index added migration friction for re-versioning workflows) and the constraint is reachable via a future projection-level check if pilot demand surfaces.
Cross-Module boundaries¶
| Module | Relationship | What's exchanged |
|---|---|---|
| Equipment | depends-on | Method.needed_family_ids 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; RoleRequirement.role_kind and Capability.suggested_role_ids reference global Role contract ids resolved at the handler edge via RoleLookup; the bind_plan_role role_kind path reads Family.presents_as + Family.affordances (via FamilyLookup) and a composed Assembly.presents_as (via AssemblyLookup) |
| Operation | shared-enum-with, expands | Capability.executor_shapes lists Procedure as a valid implementer; Procedure.capability_id (Operation BC) points back at a Capability declared here; Operation's register_procedure_from_recipe reads a Recipe and expands its templated steps into the Procedure's flat step list (recorded as RecipeExpansionRecorded) |
| 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_calibration_ids (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"],
"parameters_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"],
"parameters_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_family_ids": ["<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 parameters_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": "2-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 family_ids; if that union does not cover the Method's needed_family_ids the response is 409 Conflict with PlanFamiliesNotSatisfiedError 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.