Operation module stable¶
Purpose & Scope¶
The Operation module models one execution of an episodic operational task: bakeout, characterization, optical alignment, beam-mode change, recovery procedure, ID maintenance, KB switching. Operators register a Procedure, start it, append per-step records (setpoint applied, action performed, check verified), then close it via complete, abort, or truncate. Both instrument-level and facility-envelope procedures share this aggregate.
A Procedure is distinct from a Run by what it leaves of record: a Run exists to produce a primary Dataset (a measurement or computed lot; ISA-88 batch lens), while a Procedure changes or verifies equipment state and leaves a Calibration value or only a step log, never a Dataset-of-record (ISA-106 lens). The Subject is metadata on a Run, not the discriminator: calibration captures are subject-less Runs. See the Run vs Procedure boundary rule. When parent_run_id is set, the Procedure is a Phase-of-Run (alignment invoked mid-Run); when None, it stands alone (bakeout between Runs).
Execution. Walking a Procedure step by step, applying each setpoint, running each action, verifying each check, is an optional edge runtime CORA offers for facilities that choose it. The Conductor dispatches steps through a substrate-neutral ControlPort, with EPICS Channel Access and PVA adapters shipped; a facility may use it or keep its own tooling. Its lower bound is the deterministic real-time loop, which stays in the control system. See the recording spine and the optional execution edge.
Iteration. Many procedures converge over repeated passes: an optical alignment nudges a mirror, re-measures, and repeats until the beam is centered. CORA models each pass as a first-class iteration through the ProcedureIterationStarted / ProcedureIterationEnded boundary pair, recording per-pass timing and a convergence verdict (converged true, false, or no-verdict, plus an optional reason). Iteration is orthogonal to the lifecycle FSM: it is a counter and a per-pass read model on a Running Procedure, not a status. An optional max_consecutive_unconverged_iterations cap, set at register time (the "patience" of early-stopping vocabulary), lets the loop give up: once that many consecutive passes end without converged=true, start_iteration refuses with ProcedureIterationLimitReachedError and the operator or agent decides whether to abort, truncate, or complete. A converged=true pass resets the streak. Before iteration was first-class, alignment scenarios smuggled the pass number into a free-form evidence['iteration'] key; that ad-hoc convention is now retired and banned by a fitness test.
Steering. A convergence loop follows a fixed recipe; a steered loop asks a brain where to look next. conduct_until_advised is the decide-axis twin of the convergence loop: after each pass it hands the accumulated evidence to a DecidePort and seeds the next pass from the point the brain advises, until the brain advises stop. The seam is deliberately optimizer- and action-neutral, expressed as six nouns: an objective (what "good" means, named by a measurement), a search space of axes, the evidence so far, one observation, the advice returned, and a budget. Optimizer internals (kernel, acquisition, surrogate) never cross the seam; a next point is coordinates keyed by axis name, never a command, so translating a point into steps stays the loop's job. The brain is handed the full history every call and is assumed to hold no memory between calls, which keeps a pure-function brain and a stateful one behind one surface. Shipped brains: an in-memory fake, a deterministic grid_walk sweep, a sobol low-discrepancy initial-design seeder, a botorch Gaussian-process Bayesian-optimization brain, and a staged composite that seeds with Sobol then hands off to the GP once enough successful observations exist. The GP brain and Sobol seeder need the optional bo dependency group (BoTorch, on PyTorch); the base install stays lean. When a learning brain decides a pass, the fitted model's summary scalars (per-axis lengthscales, observation noise, acquisition value) are captured for audit as one row per iteration in a per-Procedure diagnostics logbook (a Diagnostic entry kind alongside the Activity step log; a side table that does not fold into aggregate state), so a reviewer can later reconstruct why the brain advised a given point. Because a Gaussian-process fit is not bit-reproducible across environments even with a fixed seed, a GP-steered run cannot be reconstructed by re-asking the brain. Instead the loop records the coordinate the brain advised each pass (advised_next_point on the iteration event), surfaced alongside the verdict and deciding brain in the per-iteration read model, so a finished GP-steered run is reconstructable by reading the recorded decision trail. The run stays classified "forward-only" (meaning not re-ask-reproducible, distinct from not-recorded and, now, distinct from not-resumable). A crashed GP-steered run resumes through reconduct_until_advised: it rebuilds the brain's history from the record, the measured value of each closed pass from a per-Procedure outcome logbook (an Outcome entry kind, sibling to the diagnostics log) and the advised coordinate from the iteration trail, then consults the brain only at the open frontier. Resume neither re-asks the brain for a pass already done nor re-drives hardware to a past position: the next move is an absolute one issued from wherever the instrument now sits, which reaches the same commanded point regardless of the current position, and direction-of-approach consistency (anti-backlash) is a per-move concern of the motion controller, not of resume. True sample path-dependence (thermal or radiation history, mechanical creep) is shaped by the whole trajectory rather than any single point, so it belongs in provenance, not in a resume-time re-drive; re-establishing one historical setpoint would neither be necessary nor sufficient. See the recording spine and the optional execution edge.
Out of scope
- Held / Resumed transitions. No pause-and-resume cycle today. The pilot will surface whether operators need it; the additive-state pattern keeps the door open.
- Verifying as a first-class FSM state. Per-step Check happens inside Running synchronously; the standards corpus does not bless a separate Verifying state.
- Per-kind payload validation at the API. The step
payloadbody isdict[str, Any]today; per-kind Pydantic models land once pilot vocabulary settles. - Asset-existence verification at register time.
target_asset_idsis taken at face value; existence and decommission-state gating runs at start-procedure time. - Procedure declares its output quantity. A Procedure does not declare which Calibration quantity it yields (an alignment producing
rotation_center, a characterization producingdetector_pixel_size). The human bridging an alignment to a Calibration knows this implicitly; an automaticProcedureCompletedagent would need it declared. Deferred until that agent is built.
Aggregates¶
| Name | Identity | State summary | FSM |
|---|---|---|---|
Procedure |
id: UUID (opaque) |
name, kind, target asset ids, status, optional parent_run_id, optional activity_logbook_id, optional capability_id, optional recipe_id, iteration_count, optional current_iteration_index, consecutive_unconverged_iterations, optional max_consecutive_unconverged_iterations |
yes |
Value Objects¶
| Name | Shape | Where used |
|---|---|---|
ProcedureName |
trimmed bounded text, 1-200 chars | Procedure.name |
ProcedureAbortReason |
trimmed bounded text, 1-500 chars; decider-input only | abort_procedure body |
ProcedureTruncateReason |
trimmed bounded text, 1-500 chars; decider-input only | truncate_procedure body |
ProcedureStatus |
closed StrEnum {Defined, Running, Completed, Aborted, Truncated} |
Procedure.status |
StepKind |
closed Literal["setpoint", "action", "check"] |
per-step entry rows |
Procedure.kind is a bare str (1-50 chars, validated at the decider) rather than a VO, mirroring the Supply.kind precedent: pilot vocabulary will settle and the field will graduate to a closed ProcedureKind StrEnum later. Documented starter vocabulary: bakeout, characterization, alignment, recovery, beam_mode_change, id_maintenance, kb_switching, optical_alignment, vacuum_regeneration.
Procedure-kind naming convention¶
When a deployment instantiates a specific operation, the kind reads <subject>_<operation-noun> with the operation noun LAST: motor_homing, center_alignment, energy_characterization, detector_z_rail_alignment, slit_centering, blade_throw_characterization. The operation noun is a noun, never a leading imperative verb: a gerund (homing, centering), a -tion / -ment (characterization, alignment), or an established operation-noun (reboot, change). It is the Capability family the procedure realizes, or a sharper operation within it (homing / centering sit under maintenance / alignment); the abstract vocabulary above names those families, and a kind qualifies one with its subject. This echoes the Family noun-LAST rule (naming).
Two anti-patterns this rules out, with the corpus already normalized to match:
- Verb-phrase-first.
center_and_close_slits->slit_centering(fold the steps into one operation noun); the coordinated movesset_energy->energy_settingandswitch_to_mono/switch_to_pink-> a singlebeam_mode_change(target mode as a parameter, not two verb-first kinds). - Act named for its value. A measuring act is a
*_characterization(blade_throw_characterization); the value it produces is a Calibration with a value-noun (blade_throw_scale), or a new revision of an existing curve (energy_characterizationre-saves theenergy_position_curve), never a procedure named*_calibration.
Narrow carve-out: capture-and-store procedures use <condition>_field (dark_field, flat_field) where the trailing noun is the produced artifact, the captured field. The convention is enforced by tests/architecture/test_procedure_kind_naming.py, which scans every RegisterProcedure(kind=...) and RegisterProcedureFromRecipe(kind=...) literal (the recipe-driven registration path carries the same kind field) against an approved operation-noun set plus the carve-out allowlist.
FSM¶
stateDiagram-v2
[*] --> Defined: register_procedure
Defined --> Running: start_procedure
Running --> Completed: complete_procedure
Running --> Aborted: abort_procedure
Running --> Truncated: truncate_procedure
Completed --> [*]
Aborted --> [*]
Truncated --> [*]
| From | To | Command | Event |
|---|---|---|---|
[*] |
Defined |
register_procedure |
ProcedureRegistered |
Defined |
Running |
start_procedure |
ProcedureStarted |
Running |
Completed |
complete_procedure |
ProcedureCompleted |
Running |
Aborted |
abort_procedure |
ProcedureAborted |
Running |
Truncated |
truncate_procedure |
ProcedureTruncated |
Iteration is orthogonal to the FSM. start_iteration and end_iteration open and close a convergence pass on a Running Procedure. They emit ProcedureIterationStarted / ProcedureIterationEnded and bump counters, but do NOT change status, so they are not rows in the table above. start_iteration is rejected unless the Procedure is Running, no iteration is already open, and the supplied iteration_index is the strict successor of iteration_count (ProcedureCannotStartIterationError); it also enforces the optional patience cap (ProcedureIterationLimitReachedError). end_iteration requires the supplied index to match the open iteration (ProcedureCannotEndIterationError) and records the convergence verdict.
Guards. Beyond the source-state check, each transition enforces:
start_procedure- Re-loads every target Asset and refuses to start if any are Decommissioned, raising
ProcedurePlanAssetDecommissionedError. Bound Capability (whencapability_idis set) must listProcedurein itsexecutor_shapes, otherwiseProcedureCapabilityExecutorMismatchError. abort_procedure/truncate_procedurereasonis REQUIRED, trimmed, 1-500 chars.truncate_procedureaccepts an optionalinterrupted_at(operator's best guess at the actual interruption time); validated to be not later thannow.append_activities- Status must be
Running. Appending to aDefined,Completed,Aborted, orTruncatedProcedure raisesProcedureStepsLogbookClosedError(the steps logbook is implicitly closed on every terminal).step_kindmust be one ofsetpoint,action,check. Producer-suppliedevent_iddeduplicates retries silently viaON CONFLICT (event_id) DO NOTHING.
Events¶
| Event | Payload sketch | When emitted |
|---|---|---|
ProcedureRegistered |
procedure_id, name, kind, target_asset_ids, parent_run_id?, capability_id?, max_consecutive_unconverged_iterations?, occurred_at |
register_procedure accepted; status implicitly Defined. |
ProcedureStarted |
procedure_id, occurred_at |
start_procedure accepted (Defined → Running). |
ProcedureActivitiesLogbookOpened |
procedure_id, logbook_id, kind="steps", schema, occurred_at |
First append_activities call for the Procedure (lazy open). |
ProcedureIterationStarted |
procedure_id, iteration_index, occurred_at |
start_iteration accepted; opens a convergence pass on a Running Procedure. Bumps iteration_count, does not change status. |
ProcedureIterationEnded |
procedure_id, iteration_index, converged?, reason?, occurred_at |
end_iteration accepted; closes the open pass with its convergence verdict. Resets the unconverged streak on converged=true, otherwise increments it. |
ProcedureCompleted |
procedure_id, occurred_at |
complete_procedure accepted (Running → Completed). |
ProcedureAborted |
procedure_id, reason, occurred_at |
abort_procedure accepted (Running → Aborted). |
ProcedureTruncated |
procedure_id, reason, interrupted_at?, occurred_at |
truncate_procedure accepted (Running → Truncated). |
RecipeExpansionRecorded |
procedure_id, recipe_id, recipe_version?, capability_id, capability_version?, bindings, expansion_port_version, steps_hash, bindings_hash, step_count, occurred_at |
register_procedure_from_recipe accepted; written alongside ProcedureRegistered to record the Recipe-to-steps expansion provenance. No-op fold on Procedure state. |
Per-step records (one row per setpoint, action, or check) write directly to the entries_operation_procedure_activities table via the ActivityStore port, NOT as events on the Procedure stream. No ProcedureStepsLogbookClosed event is emitted; the FSM terminal IS the close signal.
Slices¶
| Command / query | REST | MCP tool |
|---|---|---|
AbortProcedure |
POST /procedures/{procedure_id}/abort |
abort_procedure |
AppendProcedureActivities |
POST /procedures/{procedure_id}/activities |
append_activities |
AppendProcedureDiagnostics |
POST /procedures/{procedure_id}/diagnostics |
append_diagnostics |
AppendProcedureOutcomes |
POST /procedures/{procedure_id}/outcomes |
append_outcomes |
CompleteProcedure |
POST /procedures/{procedure_id}/complete |
complete_procedure |
conduct_from_procedure |
POST /procedures/{procedure_id}/conduct-from |
conduct_from_procedure |
conduct_or_hold_procedure |
POST /procedures/{procedure_id}/conduct-or-hold |
conduct_or_hold_procedure |
conduct_procedure |
POST /procedures/{procedure_id}/conduct |
conduct_procedure |
command_from_wire |
POST /procedures/{procedure_id}/conduct-until-advised |
conduct_until_advised |
command_from_wire |
POST /procedures/{procedure_id}/conduct-until-advised-from |
conduct_until_advised_from |
conduct_until_converged |
POST /procedures/{procedure_id}/conduct-until-converged |
conduct_until_converged |
EndProcedureIteration |
POST /procedures/{procedure_id}/iterations/end |
end_iteration |
GetProcedure |
GET /procedures/{procedure_id} |
get_procedure |
HoldProcedure |
POST /procedures/{procedure_id}/hold |
hold_procedure |
ListProcedureIterations |
GET /procedures/{procedure_id}/iterations |
list_procedure_iterations |
ListProcedures |
GET /procedures |
list_procedures |
RegisterProcedure |
POST /procedures |
register_procedure |
RegisterProcedureFromRecipe |
POST /procedures/from-recipe |
register_procedure_from_recipe |
ResumeProcedure |
POST /procedures/{procedure_id}/resume |
resume_procedure |
StartProcedureIteration |
POST /procedures/{procedure_id}/iterations/start |
start_iteration |
StartProcedure |
POST /procedures/{procedure_id}/start |
start_procedure |
TruncateProcedure |
POST /procedures/{procedure_id}/truncate |
truncate_procedure |
Errors per slice. Beyond Pydantic boundary 422s, each slice raises:
RegisterProcedureProcedureAlreadyExistsError,InvalidProcedureNameError,InvalidProcedureKindError,InvalidProcedureIterationCapError(the optional patience cap must be>= 1),Unauthorized.register_procedure_from_reciperaises the same set plus its Recipe-expansion errors.StartProcedureProcedureNotFoundError,ProcedureCannotStartError,ProcedurePlanAssetDecommissionedError,ProcedureCapabilityExecutorMismatchError,ProcedureRequiresAvailableSupplyError(no Supply registered for a kind in the parent Run'sMethod.needed_supplies),ProcedureSupplyCoverageMismatchError(Supplies exist but none Available), and (for Phase-of-Run Procedures only)RunNotFoundError/PlanNotFoundError/PracticeNotFoundError/MethodNotFoundErrorif the parent-resolution chain has a broken link,Unauthorized. The Supply gate fires only whenparent_run_idis set; standalone Procedures pass trivially today (Capability-levelneeded_suppliesis a watch item).AppendProcedureActivitiesProcedureNotFoundError,ProcedureStepsLogbookClosedError,InvalidStepKindError,UnauthorizedCompleteProcedure/AbortProcedure/TruncateProcedureProcedureNotFoundError,ProcedureCannot<Verb>Error(single-source fromRunning),Unauthorized. Abort additionally raisesInvalidProcedureAbortReasonError; Truncate additionally raisesInvalidProcedureTruncateReasonErrorandInvalidProcedureInterruptedAtError.StartIterationProcedureNotFoundError,ProcedureCannotStartIterationError(notRunning, an iteration is already open, or the supplied index is not the strict successor ofiteration_count),ProcedureIterationLimitReachedError(the patience cap was reached; 409),UnauthorizedEndIterationProcedureNotFoundError,ProcedureCannotEndIterationError(no open iteration, or the supplied index does not match the open one),InvalidProcedureIterationEndReasonError,UnauthorizedGetProcedureProcedureNotFoundErrorListProcedures/ListProcedureIterations- (boundary 422 only)
Storage & Projections¶
proj_operation_procedure_summary:
CREATE TABLE proj_operation_procedure_summary (
procedure_id UUID PRIMARY KEY,
name TEXT NOT NULL,
kind TEXT NOT NULL,
target_asset_ids UUID[] NOT NULL DEFAULT '{}',
parent_run_id UUID,
status TEXT NOT NULL CHECK (
status IN ('Defined', 'Running', 'Completed', 'Aborted', 'Truncated')
),
activity_logbook_id UUID,
registered_at TIMESTAMPTZ NOT NULL,
last_status_changed_at TIMESTAMPTZ,
last_status_reason TEXT,
interrupted_at TIMESTAMPTZ,
iteration_count INTEGER NOT NULL DEFAULT 0 CHECK (iteration_count >= 0),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX proj_operation_procedure_summary_keyset_idx
ON proj_operation_procedure_summary (registered_at, procedure_id);
CREATE INDEX proj_operation_procedure_summary_target_assets_gin_idx
ON proj_operation_procedure_summary USING GIN (target_asset_ids);
last_status_changed_at updates on every transition out of Defined; last_status_reason is populated by Aborted and Truncated only (Completed is happy-path, no reason). interrupted_at is Truncated-only and carries the operator's best guess at when the actual interruption happened (distinct from last_status_changed_at, which is when the truncate command was processed). activity_logbook_id is NULL until the first step is appended and is set by ProcedureActivitiesLogbookOpened independently of any lifecycle transition. iteration_count is the single-row denorm of how many iterations the Procedure has begun, folded from ProcedureIterationStarted; "how many passes did this alignment take" is then a plain column read rather than a per-kind dig into the free-form step evidence.
proj_operation_procedure_iterations:
CREATE TABLE proj_operation_procedure_iterations (
procedure_id UUID NOT NULL,
iteration_index INTEGER NOT NULL,
started_at TIMESTAMPTZ NOT NULL,
ended_at TIMESTAMPTZ,
converged BOOLEAN,
reason TEXT,
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
PRIMARY KEY (procedure_id, iteration_index)
);
CREATE INDEX proj_operation_procedure_iterations_by_started_idx
ON proj_operation_procedure_iterations (procedure_id, started_at);
CREATE INDEX proj_operation_procedure_iterations_converged_idx
ON proj_operation_procedure_iterations (converged)
WHERE converged IS NOT NULL;
The per-iteration convergence read model, one row per (procedure_id, iteration_index), surfaced by the list_procedure_iterations query slice. ProcedureIterationStarted inserts the row with started_at (ON CONFLICT DO NOTHING, replay-safe); ProcedureIterationEnded updates ended_at, converged, and reason by primary key. It answers in plain SQL what the single-row iteration_count denorm cannot: which passes converged (WHERE converged), time per pass (ended_at - started_at), and convergence rate. Because the verdict is already durable on the Procedure event stream, this is a rebuildable, mutable projection (truncate + replay re-derives it), not an immutable system-of-record entries_* table. The column shape deliberately equals the body a future entries_operation_procedure_iterations substream would carry, so promoting iteration writes off the aggregate stream (the trigger is any Procedure exceeding ~100 iterations in a run) is a write-tier shift with no event-shape change.
entries_operation_procedure_activities:
CREATE TABLE entries_operation_procedure_activities (
event_id uuid PRIMARY KEY,
procedure_id uuid NOT NULL,
logbook_id uuid NOT NULL,
actor_id uuid NOT NULL,
command_name text NOT NULL,
step_kind text NOT NULL,
payload jsonb NOT NULL,
sampled_at timestamptz NOT NULL,
occurred_at timestamptz NOT NULL,
correlation_id uuid NOT NULL,
causation_id uuid,
recorded_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX entries_operation_procedure_steps_proc_sampled_idx
ON entries_operation_procedure_activities (procedure_id, sampled_at DESC);
CREATE INDEX entries_operation_procedure_steps_proc_kind_sampled_idx
ON entries_operation_procedure_activities (procedure_id, step_kind, sampled_at DESC);
CREATE INDEX entries_operation_procedure_steps_logbook_idx
ON entries_operation_procedure_activities (logbook_id);
CREATE INDEX entries_operation_procedure_steps_recorded_at_brin_idx
ON entries_operation_procedure_activities USING BRIN (recorded_at);
REVOKE UPDATE, DELETE, TRUNCATE ON entries_operation_procedure_activities FROM cora_app;
Polymorphic-with-discriminator: one row per step, with step_kind discriminating between setpoint, action, and check, and the per-kind body shape carried in the payload jsonb column. The table is append-only at the role level (UPDATE / DELETE / TRUNCATE revoked); event_id is the producer-supplied UUIDv7 idempotency key, so retrying a step submission with the same id is a silent no-op via ON CONFLICT (event_id) DO NOTHING. Three timestamps are recorded per entry: sampled_at (when the step physically happened in the field), occurred_at (when the handler processed the append), and recorded_at (when Postgres wrote the row).
Cross-Module boundaries¶
| Module | Relationship | What's exchanged |
|---|---|---|
Trust |
gated-by | Every write-side Operation slice (register_procedure, start_procedure, step appenders, terminal transitions) is gated by the Authorize port resolving a Policy for the (principal, command, conduit, surface) tuple; deny outcomes refuse before the decider runs. |
Access |
shared-id-with | Every Procedure event envelope carries actor_id for principal attribution; cross-module references are bare UUIDs and not verified at write time. |
Equipment |
reads-from | target_asset_ids references Asset aggregates. Existence and Decommissioned-lifecycle gating runs at start_procedure time via ProcedureStartContext, NOT at register-time. |
Run |
reads-from (load-bearing for Supply gate) | Optional parent_run_id resolves the Phase-of-Run question: a Procedure with parent_run_id set is a Phase invoked mid-Run; None is a standalone Procedure. For Phase-of-Run Procedures, start_procedure loads the parent Run (then Plan → Practice → Method) to derive the needed_supplies for the Supply gate. A broken link anywhere in that chain raises a strict <Aggregate>NotFoundError rather than silently bypassing the gate. The Operation module does NOT load Run for standalone Procedures. |
Recipe |
reads-from (load-bearing) | Optional capability_id binds a Procedure to the universal Capability template. The bound Capability must list Procedure in its executor_shapes, enforced at start_procedure. For Phase-of-Run Procedures start_procedure also loads Plan → Practice → Method to derive the parent's needed_supplies for the Supply pre-flight gate. |
Supply |
reads-from (load-bearing for Phase-of-Run) | SupplyLookup.find_supplies_by_kind(kinds=method.needed_supplies) returns every non-Decommissioned Supply grouped by kind; the decider refuses to start unless every required kind has ≥1 Supply in Available. Raises ProcedureRequiresAvailableSupplyError or ProcedureSupplyCoverageMismatchError (both 409). Only fires for Phase-of-Run Procedures; standalone Procedures skip the gate today. |
Safety |
reads-from | start_procedure calls the Clearance lookup via ProcedureBinding references; at least one Active Clearance must cover the Procedure scope or start rejects. |
Caution |
reads-from | start_procedure calls CautionLookup for matching Active Cautions; non-blocking, surfaced as a banner on the response, never refuses start. |
Examples¶
The four examples below follow the canonical Procedure path: register an alignment targeting one Asset, start it, append one setpoint step + one check step, then complete it. The append_activities slice carries producer-supplied event_id per entry for safe retries (Idempotency-Key is not used at this slice). For the REST/MCP equivalence, auth, and idempotency conventions these examples share, see Reading the examples on the Modules landing page.
Register a Procedure¶
POST /procedures
Content-Type: application/json
Idempotency-Key: 9a7d2c3e-4b1f-4f6a-8a2e-5c2c4f3a7b91
X-Principal-Id: 7b1f2d4e-2a3c-4d5e-8f9a-1b2c3d4e5f60
{
"name": "Beamline 2-BM rotary stage alignment",
"kind": "alignment",
"target_asset_ids": ["c1f2d3c4-b5a6-4978-8869-7a6b5c4d3e2f"]
}
A successful call returns 201 Created with {"procedure_id": "<uuid>"}. The Procedure starts in Defined.
Start the Procedure¶
A successful call returns 204 No Content. Status moves to Running; the handler pre-loads each target Asset and refuses to start if any are Decommissioned.
Append a setpoint and a check step¶
POST /procedures/{procedure_id}/steps
Content-Type: application/json
X-Principal-Id: 7b1f2d4e-2a3c-4d5e-8f9a-1b2c3d4e5f60
{
"entries": [
{
"event_id": "0190f001-aaaa-7000-8000-000000000001",
"step_kind": "setpoint",
"payload": {
"channel": "rotary.theta",
"target_value": 90.0,
"units": "deg",
"ramp_rate": 5.0
},
"sampled_at": "2026-05-20T14:32:11Z"
},
{
"event_id": "0190f001-aaaa-7000-8000-000000000002",
"step_kind": "check",
"payload": {
"channel": "rotary.theta",
"expected": 90.0,
"actual": 89.998,
"tolerance": 0.01,
"passed": true
},
"sampled_at": "2026-05-20T14:32:18Z"
}
]
}
A successful call returns 200 OK with {"event_count": 2}. The first call also emits ProcedureActivitiesLogbookOpened on the Procedure stream (lazy open). Re-issuing the same event_id values silently dedupes via ON CONFLICT (event_id) DO NOTHING.
mcp.call_tool(
"append_activities",
{
"procedure_id": "<uuid>",
"entries": [
{
"event_id": "0190f001-aaaa-7000-8000-000000000001",
"step_kind": "setpoint",
"payload": {
"channel": "rotary.theta",
"target_value": 90.0,
"units": "deg",
"ramp_rate": 5.0,
},
"sampled_at": "2026-05-20T14:32:11Z",
},
{
"event_id": "0190f001-aaaa-7000-8000-000000000002",
"step_kind": "check",
"payload": {
"channel": "rotary.theta",
"expected": 90.0,
"actual": 89.998,
"tolerance": 0.01,
"passed": True,
},
"sampled_at": "2026-05-20T14:32:18Z",
},
],
},
)
Returns the same response shape as the REST call.
Complete the Procedure¶
A successful call returns 204 No Content. Status moves to Completed; the steps logbook is implicitly closed (subsequent append_activities calls return 409 Conflict).