REST API Reference¶
The DCS Gateway exposes a REST API on port 8090 (configurable via gateway.httpPort in Helm values). All endpoints return JSON. Site-scoped resources use the URL pattern /api/v1/sites/{site}/..., where {site} is the site name (the gateway maps it to the site-{site} Kubernetes namespace).
Base URL: http://<gateway-host>:8090
Health¶
| Method | Path | Description |
|---|---|---|
GET |
/healthz |
Liveness probe |
GET |
/readyz |
Readiness probe |
Response: {"status": "ok"}
Overview¶
| Method | Path | Description |
|---|---|---|
GET |
/api/v1/overview |
Plant-wide overview (all enterprises, sites, units) |
GET |
/api/v1/sites/{site}/overview |
Single site overview with unit states and batch counts |
Servers¶
| Method | Path | Description |
|---|---|---|
GET |
/api/v1/servers |
Kubernetes nodes running the control software, classified at read time (requires a live cluster) |
GET |
/api/v1/servers/{node}/preflight |
What breaks if this node goes away: etcd quorum after its loss, unit runtimes hosted here, node-pinned volumes and what they back, pods that cannot move and the mechanism holding each one, and the alarms the loss would raise |
GET |
/api/v1/servers/{node}/events |
Recent Kubernetes events about this node, newest first and capped at 50. Kubernetes expires events after about an hour, so an empty list says only that nothing happened recently |
GET |
/api/v1/system/power-capabilities |
Which power verbs the configured provider can perform, and the verb set each repairability class may be offered. Answers from the deployment's configuration, so it needs no live cluster |
GET |
/api/v1/servers/{node}/maintenance |
Whether this node is cordoned, whether a maintenance of ours cordoned it, and the open NodeMaintenance if there is one |
POST |
/api/v1/servers/{node}/cordon |
Take this node out of service. Records a NodeMaintenance with intent: cordon that the physical operator executes; the gateway never writes the Node. Requires a reason meeting the deployment reason policy |
POST |
/api/v1/servers/{node}/uncordon |
Return this node to service, from a cordon. Sets spec.abandon on the open NodeMaintenance, which uncordons what it cordoned. Requires a reason meeting the deployment reason policy |
POST |
/api/v1/servers/{node}/drain |
Cordon this node and evict what can move. Requires a reason and acknowledgedPreflightAt, the timestamp of the impact check that was acknowledged, which must be under 10 minutes old |
POST |
/api/v1/servers/{node}/reboot |
Drain this node and restart it through the configured power provider. Requires a reason, acknowledgedPreflightAt, and confirmNode matching the node name exactly. Refused where the provider does not advertise reboot for this node's repairability class |
POST |
/api/v1/servers/{node}/shutdown |
Drain this node and power it off. Same ceremony as reboot. Offered only by a provider that also advertises power-on, so an in-band channel refuses it (ADR 0034 § 4) |
POST |
/api/v1/servers/{node}/power-on |
Start a machine that is off, through the configured power provider. Requires a reason and nothing else: the ceremony ladder is graded by irreversibility and this is the reversal. Refused unless the provider advertises power-on, which means an out-of-band channel — an in-band one cannot reach a machine that is off. Cordons nothing and lifts no cordon; returning the node to service is a separate act |
POST |
/api/v1/servers/{node}/maintenance/abandon |
Stop a drain, reboot or shutdown where it is and return the node to service. What that undoes depends on the phase reached, and the response says which. Requires a reason meeting the deployment reason policy |
GET |
/api/v1/servers/{node}/identity |
What the plant calls this machine, read from the node at request time, beside the name this product authored and the operator's report on it. editable is false for a simulation fixture and for a controller-bound node, which is named through its Controller (ADR 0040) |
PUT |
/api/v1/servers/{node}/identity |
Set what the plant calls this machine. Records a NodeIdentity that the physical operator applies to the node's dcs.io/display-name annotation; the gateway never writes the Node. An empty displayName clears the name, and only a value this product applied is removed |
| GET | /api/v1/sites/{site}/outage | The outage in flight for this site, if any, plus which form (unattended or attended) this deployment would give it and why. A sequence resting at Quiesced carries parkedFrom (the step a continue resumes, and what tells a surface continue is offerable) and continuing (a continue the operator has not consumed yet) |
| GET | /api/v1/sites/{site}/outage/preflight | What a site outage would interrupt: what is in flight and would refuse it, the units and nodes it would stop, and the form |
| POST | /api/v1/sites/{site}/outage | Take this site out of production, in order (ADR 0038). Records a SiteOutage the physical operator runs. Requires a reason, confirmSite matching the site name exactly, acknowledgedPreflightAt, and on the attended form attendedAcknowledged |
| POST | /api/v1/sites/{site}/outage/abandon | Stop a sequence in flight and return the site to service. Reachable from every step that can stall |
| POST | /api/v1/sites/{site}/outage/continue | Resume a sequence parked at Quiesced from the step that stopped. Refuses anything not parked: there is nothing to resume |
| GET | /api/v1/sites/{site}/return | Where the site's return to service has got to: six stages derived from live state, each naming the observable it read and what it cannot see. systemBack covers the five control-system stages and is what decides whether a return is still in progress; converged adds the units and never means ready for work. Carries the latest terminal outage as context when one exists, and reports the same stages without it when one does not |
Any action that would consume the last of the etcd quorum margin additionally
requires confirmNode, whichever verb it is. An action that would drop ready
control-plane nodes below quorum is refused outright and is never upgraded into
a ceremony.
The gateway reads the cluster's Node objects directly (there is no parallel CRD) and classifies each one: a node bound to a Controller CR is controller (it already appears in that site's Infrastructure branch), a node carrying the node-role.kubernetes.io/control-plane label is control-plane, and anything else is worker. Servers deliberately stay outside the ISA-88 physical model.
Response (ServersDTO):
{
"cluster": {
"nodeCount": 5,
"controlPlaneCount": 3,
"controlPlaneReady": 3,
"workerCount": 0,
"controllerCount": 2,
"etcdQuorum": {"members": 3, "readyMembers": 3, "quorumSize": 2, "healthy": true}
},
"nodes": [
{
"name": "cp-1",
"displayName": "dcs-server-1",
"classification": "control-plane",
"roles": ["control-plane"],
"ready": "True",
"kubeletVersion": "v1.31.2",
"osImage": "Talos (v1.8.3)",
"internalIP": "10.10.10.11",
"cpu": "4",
"memory": "8Gi",
"conditions": [{"type": "Ready", "status": "True", "reason": "KubeletReady"}],
"boundPVs": [{"name": "pvc-…", "capacity": "10Gi", "storageClass": "local-path", "phase": "Bound", "claimNamespace": "cloud-native-dcs-system", "claimName": "historian-data"}]
}
]
}
displayName is present when the Node carries a dcs.io/display-name annotation. It is the operator-chosen presentation name the UI renders, while name stays the identity everywhere. etcdQuorum is derived from control-plane node readiness under the stacked-etcd assumption (quorum = ⌊members/2⌋+1). The gateway does not speak to etcd directly. boundPVs lists PersistentVolumes pinned to the node via required hostname node affinity, the local-path stickiness that turns chassis maintenance into a data question. When PersistentVolumes cannot be listed, a top-level pvsUnavailable: true marks every node's bound volumes as unknown.
Power capabilities¶
Kubernetes has no power verb, so reboot and shutdown reach a chassis over a channel below the cluster. That channel is configured at the deployment layer and runs in the physical operator (ADR 0034 section 4). The gateway is told the provider's name and never its credentials, so this route answers from configuration alone.
Response (Advertisement):
{
"configured": true,
"provider": "talos",
"capabilities": ["reboot"],
"summary": "The Talos machine API, reached in band over the cluster's own network. …",
"verbsByRepairability": {
"repairable": {"verbs": ["reboot"], "reason": "The configured power provider can restart this machine, which returns by itself. …"},
"replaceable": {"verbs": [], "reason": "This node's lifecycle is owned elsewhere, …"},
"unknown": {"verbs": [], "reason": "Nothing on the Node says whether a fault here is repaired in place …"}
}
}
A provider advertises reboot, shutdown and power-on independently, and one that does not advertise power-on may not advertise shutdown: shipping shutdown over a channel that cannot power a machine back on turns a remote action into a drive to the plant. The Talos backend is in band, so it advertises reboot and withholds shutdown.
verbsByRepairability carries the derived answer, with the inputs kept out of it. A client looks its node's repairability (from the ServersDTO above) up in this map and never re-derives the rule, so the verb matrix has one implementation. A deployment with no provider configured answers "configured": false with an empty verb set for every class, and error is set when the deployment names a provider this build does not recognise.
This response no longer carries a stateReadback flag. It was served from #1237 to #1324 and read by nothing. Whether a channel can see a machine's power state is consumed in the physical operator, at the one moment it changes what the product may claim: a shutdown reports the machine observed off where the channel can see it and requested where it cannot, and the answer reaches an operator on the NodeMaintenance object's PowerOffConfirmed condition. A capability flag a client had to interpret was the shape this replaced.
Physical Hierarchy¶
Enterprises (cluster-scoped)¶
| Method | Path | Description |
|---|---|---|
GET |
/api/v1/enterprises |
List all enterprises |
POST |
/api/v1/enterprises |
Create an enterprise |
GET |
/api/v1/enterprises/{name} |
Get enterprise detail |
PUT |
/api/v1/enterprises/{name} |
Update an enterprise |
DELETE |
/api/v1/enterprises/{name} |
Delete an enterprise |
Create request:
{"name": "acme-corp", "description": "ACME Corporation"}
Response (EnterpriseDTO):
{"name": "acme-corp", "description": "ACME Corporation", "sites": ["houston-plant"], "siteCount": 1}
Sites (cluster-scoped)¶
| Method | Path | Description |
|---|---|---|
GET |
/api/v1/sites |
List all sites |
POST |
/api/v1/sites |
Create a site |
GET |
/api/v1/sites/{site} |
Get site detail |
PUT |
/api/v1/sites/{site} |
Update a site |
DELETE |
/api/v1/sites/{site} |
Delete a site |
Create request:
{"name": "houston-plant", "enterpriseName": "acme-corp", "description": "Houston facility", "location": "Houston, TX"}
Creating a site also creates the site-{name} Kubernetes namespace.
Areas¶
| Method | Path | Description |
|---|---|---|
GET |
/api/v1/sites/{site}/areas |
List areas |
POST |
/api/v1/sites/{site}/areas |
Create an area |
GET |
/api/v1/sites/{site}/areas/{name} |
Get area detail |
PUT |
/api/v1/sites/{site}/areas/{name} |
Update an area |
DELETE |
/api/v1/sites/{site}/areas/{name} |
Delete an area |
Create request:
{"name": "mixing-area", "description": "Chemical mixing area"}
Process Cells¶
| Method | Path | Description |
|---|---|---|
GET |
/api/v1/sites/{site}/processcells |
List process cells |
POST |
/api/v1/sites/{site}/processcells |
Create a process cell |
GET |
/api/v1/sites/{site}/processcells/{name} |
Get process cell detail |
PUT |
/api/v1/sites/{site}/processcells/{name} |
Update a process cell |
DELETE |
/api/v1/sites/{site}/processcells/{name} |
Delete a process cell |
Create request:
{"name": "mixing-cell-1", "areaName": "mixing-area", "description": "Primary mixing cell"}
Units¶
| Method | Path | Description |
|---|---|---|
GET |
/api/v1/sites/{site}/units |
List units |
POST |
/api/v1/sites/{site}/units |
Create a unit |
GET |
/api/v1/sites/{site}/units/{name} |
Get unit detail |
PUT |
/api/v1/sites/{site}/units/{name} |
Update a unit |
DELETE |
/api/v1/sites/{site}/units/{name} |
Delete a unit |
Create request:
{
"name": "reactor-1",
"processCellName": "mixing-cell-1",
"description": "Primary reactor",
"symbolId": "vessel-tank",
"controllerRef": "plant-controller-1"
}
I/O configuration (protocol, endpoint, etc.) is defined on IOModules. Units carry none of it. The runtime is created automatically when ControlModules reference IOModules via tags or tagBindings.
symbolId is optional and names the ISA 5.1 symbol the HMI renders for the unit (e.g.
"vessel-tank", "vessel-column"). When omitted, the HMI falls back to the vessel-reactor
default glyph. On update, sending an empty symbolId clears the override back to that default.
Response (UnitDTO):
{
"name": "reactor-1",
"processCell": "mixing-cell-1",
"symbolId": "vessel-tank",
"state": "Running",
"mode": "Automatic",
"allocatedTo": "batch-001",
"activeWork": {
"kind": "UnitProcedure",
"name": "up-mixing-001",
"state": "Running",
"batchRef": "batch-001",
"since": "2026-03-19T10:05:00Z"
},
"controllerRef": "plant-controller-1",
"runtimeReady": true,
"validCommands": ["Stop", "Hold", "Abort"]
}
The activeWork field is present when a procedural element (Phase or UnitProcedure) is currently executing on the unit. It is null/absent when the unit is idle. For batch-driven work, batchRef links to the owning Batch. For ad-hoc phases it is empty.
Control Modules¶
| Method | Path | Description |
|---|---|---|
GET |
/api/v1/sites/{site}/controlmodules |
List control modules |
POST |
/api/v1/sites/{site}/controlmodules |
Create a control module |
GET |
/api/v1/sites/{site}/controlmodules/{name} |
Get control module detail |
PUT |
/api/v1/sites/{site}/controlmodules/{name} |
Update a control module |
DELETE |
/api/v1/sites/{site}/controlmodules/{name} |
Delete a control module |
Create request:
{
"name": "temp-sensor-1",
"parentName": "reactor-1",
"parentKind": "Unit",
"templateRef": "basic-temp-input",
"symbolId": "inst-temperature",
"measuredVariable": "Temperature",
"description": "Reactor temperature sensor"
}
templateRef is required and names the ControlModuleTemplate that provides the control logic and device type classification. The template's moduleType and deviceClass are automatically copied to the control module's status.moduleType and status.deviceClass on reconciliation. deviceClass (valve, actuator, sensor, controller, other) is the declared HMI rendering category that drives card grouping and gauge layout. The HMI performs no inference from moduleType, which stays a free-form identifier (ADR 0016). Control modules whose template declares no deviceClass render in the generic "other" group.
symbolId is optional. When set, it specifies the ISA 5.1 symbol for HMI rendering (e.g., "valve-butterfly", "motor-vfd", "inst-temperature"). If omitted and the template has a symbolId, the template's value is inherited. Otherwise, the HMI falls back to a lookup based on measuredVariable, then status.moduleType.
tags is served merged and stored unmerged, and the difference is marked. A GET returns the module's effective tags, which are the template's tags plus the ones the instance declares. An entry carrying "inherited": true is the template's. A PUT writes spec.tags, so it takes only the tags the instance owns. Sending an inherited entry back would copy the template's tag onto the instance and stop later template edits from reaching it, and the request is refused with a 400 naming the tag instead. Every other field of a tag is served and accepted, including the ones no editor renders (channel, engMin, engMax, engUnits, trueLabel, falseLabel), because a client that rebuilds the array from a GET would otherwise delete what it was not sent.
measuredVariable is optional and names the ISA-5.1 measured variable the instrument represents: one of Temperature, Pressure, Flow, Level, Analytical, Speed, Weight. It drives the instrument glyph (a temperature element and a flow transmitter render differently) and the tag-letter shorthand shown on HMI cards (e.g. TI for a temperature indicator, PC for a pressure control loop). Like symbolId, an unset value is inherited from the template, and the effective value is surfaced on status.measuredVariable. Leave it empty for non-instruments (valves, motors) or when the variable is unknown. The HMI then shows a generic gauge and implies no variable.
I/O Modules¶
| Method | Path | Description |
|---|---|---|
GET |
/api/v1/sites/{site}/iomodules |
List I/O modules |
POST |
/api/v1/sites/{site}/iomodules |
Create an I/O module |
GET |
/api/v1/sites/{site}/iomodules/{name} |
Get I/O module detail |
PUT |
/api/v1/sites/{site}/iomodules/{name} |
Update an I/O module |
DELETE |
/api/v1/sites/{site}/iomodules/{name} |
Delete an I/O module |
Create request:
{
"name": "turck-io-1",
"controllerRef": "plant-controller-1",
"protocol": "modbus",
"address": "10.0.1.100:502",
"options": {"unitId": "1"}
}
controllerRef is optional for the network protocols (modbus, ethernetip, opcua). A network-reached device owns no compute node, so an omitted ref hands the module to a namespace-shared network-io-probe (ADR 0021). It is required for protocol: simulation, whose liveness derives from the simulation Controller's heartbeat. On a zoned OT network a controller-less module may declare probePlacement.nodeSelector to pin its shared probe to the nodes with field reach (ADR 0042). The field is refused alongside a controllerRef. Whichever shape the module has, status.probePodName names the io-probe pod that serves it. A placement puts the module in a group named network-io-probe-<hash of the selector>, so read the name and never derive it.
Simulation Presets¶
Presets are reusable equipment simulation templates (SimulationPreset CRDs). They define behavior models using generic address names that get mapped to real IOModule addresses when used by a profile.
| Method | Path | Description |
|---|---|---|
GET |
/api/v1/sites/{site}/simulationpresets |
List simulation presets |
POST |
/api/v1/sites/{site}/simulationpresets |
Create a simulation preset |
POST |
/api/v1/sites/{site}/simulationpresets/seed |
Seed the built-in presets |
GET |
/api/v1/sites/{site}/simulationpresets/{name} |
Get simulation preset detail |
PUT |
/api/v1/sites/{site}/simulationpresets/{name} |
Update a simulation preset |
DELETE |
/api/v1/sites/{site}/simulationpresets/{name} |
Delete a simulation preset |
Create request:
{
"name": "reactor-vessel",
"description": "Jacketed reactor vessel with heating/cooling, agitation, and level tracking",
"behaviors": [
{"address": "temperature_pv", "type": "PIDResponse", "params": {"coAddr": "jacket_sp", "gain": "1.0", "timeConstantSec": "300.0"}},
{"address": "level", "type": "TankLevel", "params": {"inFlowAddr": "inlet_flow", "outFlowAddr": "outlet_flow", "capacity": "1000.0", "maxFlowRate": "1.0"}}
]
}
A numeric or boolean param whose value does not parse answers 400, and the
message names the parameter. Create refuses any such value. Update refuses only
when the request supplies behaviors, so a preset that is already unloadable
stays editable. The same check runs on IOModule create and update for an inline
spec.simulation block. POST /api/v1/apply is exempt, which keeps a backup
restorable. See ADR 0064.
Simulation Configuration (IOModule inline)¶
Simulation is configured inline on each IOModule under spec.simulation (type SimulationIOConfig). There is no separate SimulationProfile CRD. The IOModule owns its simulation configuration directly.
A spec.simulation block may reference a SimulationPreset for reusable templates and/or define inline behaviors and faults. When both are present, inline behaviors override any preset-expanded behavior whose resolved address matches (inline-wins semantics). The unit controller expands presets at reconcile time and forwards the fully-expanded configuration to the runtime via a ConfigMap.
IOModule spec with preset reference:
apiVersion: physical.dcs.io/v1alpha1
kind: IOModule
metadata:
name: reactor-sim
spec:
controllerRef: pharma-controller
protocol: simulation
address: "sim://local"
channels: [...]
simulation:
preset: reactor-vessel
seed: 42
addressMap:
temperature_pv: "reactor-sim:analog.0"
jacket_sp: "reactor-sim:analog.1"
When a simulation.preset is set, the create/update endpoints reject the request with 422 Unprocessable Entity unless simulation.addressMap maps every one of the preset's generic addresses to a non-empty real address. An unmapped generic resolves to its own name at runtime, silently severing the intended generic→real wiring. The gateway therefore fails the save loudly, for any client (UI, dcs apply, direct curl).
IOModule spec with inline behaviors and faults:
spec:
simulation:
seed: 123
behaviors:
- address: "sim:ai.0"
type: TankLevel
params:
inFlowAddr: "sim:ai.1"
outFlowAddr: "sim:ai.2"
capacity: "500"
maxFlowRate: "0.5"
faults:
- name: stuck-valve
address: "sim:ai.3"
type: StuckValue
params:
stuckValue: "0"
triggerMode: Manual
Available behavior types: SineWave, RandomWalk, NoisyConstant, DigitalPulse, TemperatureRamp, ValveFeedback, TankLevel, PIDResponse, Expr.
The Expr type evaluates a user-defined mathematical expression each tick, enabling cross-tag coupling (e.g., pressure as a function of level and temperature). Set params.expr to the expression string, and params.engMin/params.engMax for the engineering range. Expressions can reference other tags by name, use built-in functions (lag, ramp, integral, derivative, noise, clamp, abs, sqrt, exp, log, min, max, sin, cos, pulse, if, and, or, not), and built-in variables (self, dt, t). Example: {"address": "sim:ai.2", "type": "Expr", "params": {"expr": "clamp(level * 0.01 + temperature_pv * 0.02 + noise(0.05), 0, 10)", "engMin": "0", "engMax": "10"}}.
Available fault types: QualityBad, SensorDrift, StuckValue, WireBreak, Oscillation. Trigger modes: Manual (immediate), Timed (after triggerAfter duration).
StuckValue also freezes the channel's observation timestamp at the moment the fault activates, so the tag ages the way a genuinely frozen device does and eventually dims in the HMI as stale. Its quality stays Good, because a stuck sensor does not report its own failure. The ageing timestamp is the only evidence available. This makes it the fault to use when exercising per-tag staleness against a realistic partial failure. The other fault types keep reading fresh, including WireBreak, whose Bad quality is itself a current observation.
Editing an IOModule's spec.simulation block (or a referenced SimulationPreset) triggers a unit reconcile, which re-expands the configuration and updates the runtime ConfigMap. The running runtime picks up the change live: it re-reads the mounted config after kubelet syncs the ConfigMap volume (typically within a minute) and re-seeds the module's simulation behaviors in place, preserving current channel values and written outputs. Channel additions and driver config changes propagate the same way. No pod restart is needed.
Writing a value to a simulation channel (dcs io write) forces it: the channel holds the written value and its configured behaviors stop driving it, surviving reconfigures, until the force is released with dcs io release <module> <channel>. Releasing is only meaningful for simulation IOModules, because hardware has no override to clear.
A write to real hardware is a request to the device. What the device then holds is the device's own answer, so read it back. Two things can make the readback disagree with the write. Some couplers publish separate read and write address tables and answer a read of an output's write address from a different process image, which is what a channel's readbackAddress declares (#1689). A device can also refuse the write outright, as a WAGO watchdog register does.
Discovery reports where an output reads back, and an IOModule's status.channels[].readbackAddress carries that answer whether or not spec.channels[].readbackAddress agrees with it. When the two disagree, status.validated is false and each affected channel in the GET /api/v1/sites/{site}/iomodules/{name} response carries a read-only discoveredReadbackAddress. That is the state of a module whose channels were written down before the product knew the device reads back elsewhere, and it clears when the channels are discovered again.
Control-module logic transforms¶
A control module's logic has one canonical form, the function-block network, and Structured Text and FBD are two views of it (ADR 0012). These stateless endpoints are the single place that conversion lives, so the UI never carries a second implementation that could drift from the network.
| Method | Path | Description |
|---|---|---|
POST |
/api/v1/control-logic/to-st |
Render a function-block network as its Structured Text projection |
POST |
/api/v1/control-logic/to-network |
Compile authored Structured Text back into the function-block network |
Render (POST .../to-st): body {"name": "...", "description": "...", "scanInterval": "...", "blocks": [...], "dataConnections": [...]}. Returns {"st": "PROGRAM ..."}.
Compile (POST .../to-network): body {"st": "PROGRAM ..."}. Returns {"blocks": [...], "dataConnections": [...], "name": "...", "scanInterval": "..."}. Structured Text outside the dataflow subset (a bare FOR/WHILE/REPEAT/CASE, or general branching that has no diagram form) returns 422 Unprocessable Entity with {"diagnostics": [{"line": N, "column": N, "message": "..."}]} and an empty network. Loops and free-form control flow live on the phase/SFC path, outside control-module logic.
Control Programs¶
ControlPrograms are served under the historical /fbnetworks URL segment: the resource family was renamed to ControlProgram, but the wire path was kept for UI compatibility (#360).
| Method | Path | Description |
|---|---|---|
GET |
/api/v1/sites/{site}/fbnetworks |
List control programs |
POST |
/api/v1/sites/{site}/fbnetworks |
Create a control program |
GET |
/api/v1/sites/{site}/fbnetworks/{name} |
Get control program detail |
PUT |
/api/v1/sites/{site}/fbnetworks/{name} |
Update a control program |
DELETE |
/api/v1/sites/{site}/fbnetworks/{name} |
Delete a control program |
POST |
/api/v1/sites/{site}/fbnetworks/{name}/override-hot-swap |
Arm a one-shot hot-swap override for the next spec edit |
Hot-swap override (POST .../override-hot-swap): body {"reason": "..."}. The reason must meet the deployment reason policy (400 otherwise). The gateway sets the dcs.io/allow-hot-swap and dcs.io/hot-swap-reason annotations so the next spec edit is not deferred by the active-batch gate. The reason is recorded verbatim on the AuditRecord (21 CFR Part 11 §11.10(k)). Returns 409 Conflict when the program is managed by a ControlModule. Edit the ControlModuleTemplate instead.
Create/update request: the body is a ControlProgram document ({"metadata": {"name": "..."}, "spec": {...}}), decoded directly into the CRD type. There is no request DTO here, so spec takes the CRD's own field names and nothing else. An unrecognised field is refused with a 400. It is not ignored.
{
"metadata": {"name": "led-control"},
"spec": {
"unitRef": "edge-io",
"scanInterval": "100ms",
"blocks": [
{"name": "input1", "type": "DI", "params": {"address": "discrete.0"}, "comment": "Push-button input"},
{"name": "output1", "type": "DO", "params": {"address": "coil.0"}}
],
"connections": [
{"sourceBlock": "input1", "sourcePort": "OUT", "destBlock": "output1", "destPort": "IN"}
],
"variables": [
{"name": "PV", "dataType": "Float", "direction": "output"}
],
"variableBindings": [
{"variable": "PV", "blockRef": "input1", "portRef": "OUT"}
]
}
}
spec.unitRef, spec.scanInterval and spec.blocks are required on PUT as well as on POST. A PUT replaces the whole spec. A body that omits one of those fields erases it. spec.scanInterval must be a duration the runtime can use. That is a Go duration string such as 100ms or 1s, or a bare number read as milliseconds, and it must be greater than zero. An empty or unreadable one is refused with a 400 (#1639). It is not defaulted. The runtime substitutes 100ms for a non-positive interval, so an accepted empty value would silently retime the scan loop.
A PUT that omits spec.variables or spec.variableBindings while the stored program declares them is refused with a 400 (#1645). Send them back to keep them, or send an explicit [] to remove them. The route is still a whole-document replace, and the refusal is not a carry list. What a request names is what it gets. The one thing it may not do is delete a POU interface it never mentioned. Nothing preserved that interface before, so the tags bound through it stopped resolving on a save that answered 200.
Each spec.variableBindings entry names its block by string, and that name has to resolve (ADR 0064, #1649). A POST carrying an entry whose blockRef names no block in the same spec.blocks is refused with a 400. A PUT is refused when the request strands a binding that resolved on the stored program, which is what renaming or deleting a bound block does. A binding that was already stranded when the request arrived is carried through, so a program that reached the cluster stranded by another road stays editable and is healed by adding a block of that name back. Nothing rewrites a binding, because re-pointing one is an edit to a POU interface the FB editor does not display. The runtime skips a binding whose block it cannot find. An accepted one therefore leaves the variable never resolving and the tag reading it empty, with the program still reporting Running. POST /api/v1/apply is exempt, for the same reason it is exempt from the scanInterval check.
Response (ControlProgramDTO):
{
"name": "led-control",
"unitRef": "edge-io",
"scanInterval": "100ms",
"state": "Running",
"scanCount": 1500,
"blockCount": 2,
"blocks": [
{"name": "input1", "type": "DI", "params": {"address": "discrete.0"}, "comment": "Push-button input"},
{"name": "output1", "type": "DO", "params": {"address": "coil.0"}}
],
"dataConnections": [
{"sourceBlock": "input1", "sourcePort": "OUT", "destBlock": "output1", "destPort": "IN"}
],
"variables": [
{"name": "PV", "dataType": "Float", "direction": "output"}
],
"variableBindings": [
{"variable": "PV", "blockRef": "input1", "portRef": "OUT"}
]
}
The wiring is spec.connections on the way in and dataConnections on the way out. The read name is a leftover from the IEC 61499 era, when a network also carried event connections, and a client that writes back the name it read gets a 400. variables and variableBindings carry the spec's own names on both sides. A client writes those back exactly as it read them (#1645). The response served neither field until then. Every FB editor save erased the POU interface as a result. A client that builds its PUT body out of the GET had nothing to send back.
A ControlProgram stores exactly one form of the logic: the function-block network (blocks + connections), which is what the runtime executes (ADR 0012). There is no structuredText field. A request carrying one is rejected as an unknown field. Structured Text is an authoring view with no stored form. Compile authored ST into a network with POST /api/v1/control-logic/to-network and submit the resulting blocks/connections. Render the ST view of a stored network with POST /api/v1/control-logic/to-st. Each block has an optional comment field for user-authored annotations. Comments are rendered as (* comment *) in the ST projection and as italic text labels in the FBD diagram. The variables array declares the program's POU interface (input/output/inout variables that tags bind to by name). variableBindings maps variable names to block.port sources.
Control Module Templates (cluster-scoped)¶
| Method | Path | Description |
|---|---|---|
GET |
/api/v1/controlmoduletemplates |
List templates |
POST |
/api/v1/controlmoduletemplates |
Create a template |
GET |
/api/v1/controlmoduletemplates/{name} |
Get template detail |
PUT |
/api/v1/controlmoduletemplates/{name} |
Update a template |
DELETE |
/api/v1/controlmoduletemplates/{name} |
Delete a template |
Create/update request:
{
"name": "pushbutton",
"moduleType": "test",
"scanInterval": "100ms",
"inputs": [{"name": "di_button", "dataType": "STRING"}],
"tags": [
{"name": "PV", "dataType": "Boolean", "accessLevel": "read", "blockRef": "ix_1", "portRef": "OUT"},
{"name": "TEMP", "dataType": "Float", "accessLevel": "read", "blockRef": "ai_1", "portRef": "OUT", "engMin": "ai_1.engMin", "engMax": "ai_1.engMax"}
],
"blocks": [
{"name": "ix_1", "type": "DI", "params": {"address": "{{.inputs.di_button}}"}, "comment": "Digital input from button"}
]
}
The optional template-level deviceClass field (valve, actuator, sensor, controller, other) declares the HMI rendering category, copied to each instance's status.deviceClass on reconciliation (ADR 0016).
Template tags define tags that are automatically inherited by ControlModule instances. Each tag references a block/port in the template's control program. Instance-level tags with the same name override template-defined tags. The merged result is written to status.effectiveTags on the ControlModule during reconciliation. Tags may include optional engMin and engMax fields for per-tag engineering ranges. Values can be literal numbers (e.g., "0", "100"), template expressions (e.g., "{{.params.eng_min}}"), or block parameter references (e.g., "read_pv.engMin" to inherit the AI block's engineering minimum). When absent, the gateway falls back to CM-level parameters["eng_min"]/parameters["eng_max"] for display. A declared range is enforced on write (ADR 0050). A numeric write outside it is refused on every path (the ST WRITE builtin, both tag-write routes, and the WebSocket write frame), with a 400 naming the tag and the range. The display fallback does not extend to that barrier, because the CM-level parameters describe the module's analog process value and say nothing about each writable tag. A tag that should be bounded declares engMin/engMax itself. A bound that is absent, or that does not resolve to a number, is not enforced. Each tag may declare an optional role (state, setpoint, command, feedback, alarm, interlock), the tag's declared operator-facing meaning (ADR 0016). The HMI maps roles to widgets with no name-based inference: role: state is the card's prominent status word and faceplate STATE row (at most one per template), setpoint/command fill the setpoint bar, feedback fills the feedback bar (and the prominent value when no state tag is declared), alarm gets alarm styling, interlock is the permissive status, and roleless tags render as generic rows. Each tag may also declare an optional publishMode (cyclic or onChange), the declared publish pattern that decides whether the tag's wall-clock age means anything (ADR 0025). A cyclic tag republishes every scan, so a value older than the HMI's staleness threshold means the device stopped publishing and the value is dimmed and badged. An onChange tag publishes only when its value changes, so a steady device legitimately never republishes. Its age is not a staleness signal, and the tag is judged on liveness of the live feed as a whole. Declare the mode to match how the tag's values actually arrive. What the tag means says nothing about how it publishes, and role: interlock does not imply onChange. Tags fed by this product's unit-runtime are published cyclically (a fixed scan-rate loop with no change detection) and should be left undeclared. onChange is for tags fed by a publisher that genuinely emits only on change, such as an OPC UA server or a third-party MQTT gateway. Declaring onChange on a cyclic tag suppresses a real staleness signal. When absent the gateway serves the field empty and the HMI applies the conservative cyclic reading. The mode is never inferred from the tag name. Boolean tags may include optional trueLabel/falseLabel fields for per-tag display labels (e.g., CMD with trueLabel: "Open", falseLabel: "Close" and a separate FB tag with trueLabel: "Opened", falseLabel: "Closed" so commands and feedback read naturally in the faceplate). When absent, role: alarm tags default to Active/Normal. Otherwise the gateway falls back to CM-level parameters["true_label"]/parameters["false_label"]. Templates can also define variableBindings in the network section to map tag variable names to FBD block.port sources. For ST templates, variable bindings are unnecessary, since variables are resolved by name in the ST source.
Create/update requests may instead carry a structuredText field containing IEC 61131-3 Structured Text source. This is a compile input with no stored form. The gateway compiles it down to the canonical function-block network (the pkg/cmlogic transform) and stores only the network, and the runtime executes the network alone (ADR 0012). When structuredText is present, any blocks/dataConnections in the same request are ignored in favor of the compiled network. ST outside the compilable dataflow subset returns 422 Unprocessable Entity with located diagnostics. Reads return the stored network. Regenerate the ST view on demand with POST /api/v1/control-logic/to-st. Example with ST source:
{
"name": "custom-logic",
"moduleType": "custom",
"scanInterval": "200ms",
"structuredText": "PROGRAM custom_logic\nVAR\n pv : REAL;\nEND_VAR\n\npv := AI('sensor');\nIF pv > 100.0 THEN\n DO('alarm', TRUE);\nEND_IF\n\nEND_PROGRAM"
}
Block Catalog and Function Block Types¶
The block catalog is the read-only merged view of built-in IEC 61131-3 block types and user-defined composite FunctionBlockTypes. Composite types are authored through the /fbblocktypes endpoints (cluster-scoped, with the historical fbblocktypes URL segment kept for UI compatibility).
A catalog entry carries the block's ports and its params list, which is the configuration an instance of that block may set. Each entry in that list has a name, a dataType, an optional description and defaultValue, and a required flag. For a composite type the list is the spec.parameters the FunctionBlockType declares (#1660). This is where the FB editor learns what configuration a block takes, so a parameter absent here is one no engineer can set on the canvas.
| Method | Path | Description |
|---|---|---|
GET |
/api/v1/blocktypes |
List all block types (built-in + composite, merged) |
GET |
/api/v1/blocktypes/{name} |
Get block type detail |
GET |
/api/v1/fbblocktypes |
List composite FunctionBlockTypes |
POST |
/api/v1/fbblocktypes |
Create a composite FunctionBlockType |
GET |
/api/v1/fbblocktypes/{name} |
Get FunctionBlockType detail |
PUT |
/api/v1/fbblocktypes/{name} |
Update a FunctionBlockType |
DELETE |
/api/v1/fbblocktypes/{name} |
Delete a FunctionBlockType |
Controllers¶
| Method | Path | Description |
|---|---|---|
GET |
/api/v1/sites/{site}/controllers |
List controllers |
POST |
/api/v1/sites/{site}/controllers |
Create a controller |
GET |
/api/v1/sites/{site}/controllers/{name} |
Get controller detail |
PUT |
/api/v1/sites/{site}/controllers/{name} |
Update a controller |
DELETE |
/api/v1/sites/{site}/controllers/{name} |
Delete a controller |
Create request:
{"name": "plant-controller-1", "nodeLabels": {"dcs.io/device": "edge-io"}}
| Field | Type | Description |
|---|---|---|
name |
string | Controller name (required) |
displayName |
string | The plant's name for the chassis, up to 63 characters and free of control characters. The physical operator applies it to the bound node's dcs.io/display-name annotation |
nodeLabels |
map | dcs.io/-prefixed labels the physical operator reconciles onto the bound node |
simulation |
bool | When true, the reconciler creates a virtual Node itself, with no deployment-layer join to wait for |
Create response:
{"name": "plant-controller-1"}
Node join is the deployment layer's job (ADR 0004): the physical operator binds the Controller to the cluster node of the same name once the node appears.
Detail response includes status fields populated by the physical-operator:
phase— lifecycle phase: Pending, Joining, Joined, Failed, Removinghealth— current operational health:healthy,degraded,offline,unknownhealthReason— one-line explanation behindhealthaddress— device IP discovered from the node's InternalIPnodeRef— Kubernetes node namedisplayName— the plant's name for the chassis, resolved at read time from the bound node'sdcs.io/display-nameannotation, and empty when the node carries noneioProbePodName— name of the io-probe pod monitoring IOModule connectivityioProbeReady— whether the io-probe pod is running and readylastAlive— last heartbeat recorded for a simulation Controller
phase and health answer different questions, and a client that reads one
for the other will mislead its operator. phase is adoption: has this device
been taken into the control system. It stays at Joined when the bound node's
kubelet goes silent, because an unreachable device is still an adopted one.
health is derived at read time from the NodeReady condition, the io-probe
and the simulation heartbeat, and it is the field that says whether the device
is working right now. A Controller reporting phase: Joined with
health: offline is the normal representation of a dead node.
displayName on the update request is a distinct field from displayName on
the detail response, and the difference matters to a client. The request field
authors the name onto spec.displayName, and the gateway holds no write
privilege against a Kubernetes node (ADR 0034).
The physical operator is therefore what applies it to the node. The response field is
read back from the node at request time, which is why a name the deployment
layer set at enrolment appears there without any Controller ever having
authored it. Sending an empty string clears the name and falls back to the
Kubernetes node name. Omitting the field leaves the current name alone.
ioProbeReady is meaningful only when ioProbePodName is set. A Controller
with no IOModules has no probe pod at all, so false there reports an
absence with nothing at fault.
Deployment reason policy¶
Hazardous operations record an operator-supplied reason in the audit
trail (21 CFR Part 11 §11.10(e)/(k)). The minimum trimmed length that
justification must meet is a per-deployment parameter, and the
product hard-codes no constant for it (#687). The product ships the
mechanism and a recommended default of 10 characters (counted in
runes, so non-ASCII justifications are not penalized). The deployment
owns the number via the gateway --reason-min-length flag (env
REASON_MIN_LENGTH, Helm value gateway.policy.reasonMinLength).
The gateway is the gate: handlers that accept a reason reject requests
below the minimum with 400 Bad Request. The effective value is served
in GET /api/v1/auth/config as reasonMinLength, and every UI dialog
mirrors it in its inline validation and hint.
Endpoints enforcing the policy: irreversible batch commands (below), unit failover, interlock bypass, the ControlProgram hot-swap override, and the runtime/service restart endpoints.
Commands¶
| Method | Path | Description |
|---|---|---|
POST |
/api/v1/sites/{site}/command |
Send an ISA-88 command to a Batch or an ad-hoc execution |
Request:
{"kind": "Batch", "name": "batch-2026-001", "command": "Hold", "reason": "Upstream WFI supply interrupted"}
Response:
{"ok": true, "kind": "Batch", "name": "batch-2026-001", "command": "Hold", "message": "Hold command sent to Batch/batch-2026-001"}
Valid kinds: Batch, plus Phase, Operation and UnitProcedure when the named resource is the root of an ad-hoc execution. Per ISA-88 Clause 7.3.2 state commands flow through the Batch resource, so a procedural element that belongs to a Batch is rejected with 400 Bad Request. Command the Batch instead, and it forwards down the tree. An ad-hoc run has no Batch to forward from, so its root is addressed directly. A child of a larger ad-hoc run is not a root and is also rejected. Command the root. Unit, Procedure, and every other kind are always rejected. (Controller-to-controller forwarding down the procedural tree happens internally via annotations and is unaffected by this rule.)
A name that does not exist is rejected with 404 Not Found.
name is the resource name, which for a Batch is generated (batch-6jrkm) and is
not the batchID its spec carries. The dcs CLI accepts either handle and resolves
the batch ID onto the resource before calling this endpoint. An API client does the
same lookup itself by listing batches.
Valid commands: Start, Stop, Hold, Pause, Resume, Restart, Abort, Reset
reason field (21 CFR Part 11 §11.10(e)): a reason meeting the deployment reason policy is required for the irreversible commands Abort, Stop, Hold, and Reset. The gateway rejects them with 400 Bad Request when it is missing, blank, or shorter than the policy minimum. For the remaining commands reason is optional. Maximum length 1024 characters. Leading/trailing whitespace is trimmed. The reason is recorded on the command's AuditRecord.
Start runs a pre-flight check: if an allocated unit's runtime pod is not ready, the command is rejected with 409 Conflict. A batch let through would transition to Running and alarm moments later.
The gateway sets the dcs.io/command annotation on the Batch, which the batch-operator picks up and processes through the state machine.
Ad-Hoc Procedural Execution¶
| Method | Path | Permission | Description |
|---|---|---|---|
POST |
/api/v1/sites/{site}/units/{unit}/execute-phase |
operate |
Launch an ad-hoc Phase from a PhaseTemplate |
POST |
/api/v1/sites/{site}/units/{unit}/execute-operation |
operate-lead |
Launch an ad-hoc Operation from an equipment-oriented OperationTemplate |
POST |
/api/v1/sites/{site}/units/{unit}/execute-unitprocedure |
operate-lead |
Launch an ad-hoc UnitProcedure from an equipment-oriented UnitProcedureTemplate |
execute-operation and execute-unitprocedure reject any template whose
spec.category is not equipment. execute-unitprocedure additionally
requires a fresh OIDC token and a non-empty reason when the gateway is in
production OperatingMode (21 CFR Part 11 §11.200).
Request shape (Operation example):
{
"operationTemplateRef": "cip-cycle",
"parameterValues": [{"name": "caustic_temp_c", "value": "80"}],
"timeoutSeconds": 5400
}
timeoutSeconds overrides the template's budget for this launch only. Omit it
(or send 0) to inherit the template's. Unlike the phase CRD field, where 0
is refused, a request body carrying 0 simply means "no override".
What the budget bounds depends on the endpoint. For execute-phase it bounds
one chart run and fails the phase. For execute-operation and
execute-unitprocedure it bounds the element as a whole (wall-clock from it
entering Running, holds included) and holds it when it elapses,
forwarding the hold to the running child. Elapsing aborts nothing. An
operation or unit procedure launched without a budget on either the request
or the template runs unbounded. There is no platform default above the phase. See
Phases → budgets above the phase.
The unit must be in Idle (or have an empty state). Ad-hoc resources are
created parentless (no Batch owner) and labeled dcs.io/adhoc=true. Each
launch emits an AuditRecord tagged via the dcs.io/audit-category label
(adhoc-phase / adhoc-operation / adhoc-unitprocedure). UnitProcedure
ad-hoc in production mode also produces an electronicSignature block on the
record. See Ad-Hoc Execution for the full guide.
Unit Failover¶
Re-bind a unit's runtime to a designated standby node (ADR 0006, hold-then-resume failover). Both routes require a live cluster (unavailable in filesystem authoring mode).
| Method | Path | Permission | Description |
|---|---|---|---|
GET |
/api/v1/sites/{site}/units/{name}/failover/targets |
operate-lead |
List eligible failover targets with co-location occupancy |
POST |
/api/v1/sites/{site}/units/{name}/failover |
operate-lead |
Request re-binding the unit's runtime to a standby node |
Targets response:
{
"unit": "reactor-1",
"availabilityMode": "Failover",
"currentNode": "ipc-reactor-1",
"currentNodeDisplayName": "dcs-server-2",
"leaseState": "Expired",
"targets": [
{"node": "ipc-standby-1", "displayName": "dcs-server-3"},
{"node": "ipc-standby-2", "occupiedBy": "site-x/mixer-3"}
]
}
displayName and currentNodeDisplayName carry the node's
dcs.io/display-name annotation, the same presentation name the Servers
surface renders, and are absent when the node has none. Clients lead with
the display name so a standby is named the same way everywhere. node
stays the identity a failover request is submitted with.
Eligible targets are resolved from the unit's
spec.availability.failoverTargets (explicit node list and/or label
selector, union). The node must be enrolled (dcs.io/site label, the
ADR 0004 adoption contract) and Ready. A virtual node is excluded, and
so is the node currently holding the binding.
ineligible lists the configured targets that are real, enrolled hardware but
cannot take the runtime right now, each with the reason (not Ready). A target
set matching a simulation stand-in or an unenrolled node is misconfigured.
Waiting cures nothing there, so those stay out of it:
{
"targets": [],
"ineligible": [{"node": "ipc-standby-1", "displayName": "dcs-server-2", "reason": "not Ready"}]
}
An empty targets therefore has two distinguishable causes: an empty
configuration, which is a spec to fix, and a downed standby, which is an
incident. A client that reports both as a configuration error sends an
operator mid-incident to edit a file that is already correct.
Failover request:
{"toNode": "ipc-standby-1", "confirmFenced": true, "allowColocation": false, "reason": "primary IPC PSU failure"}
Response:
{"ok": true, "unit": "reactor-1", "toNode": "ipc-standby-1", "message": "failover of unit reactor-1 to node ipc-standby-1 requested; the physical operator fences, re-binds, and reports via the FailoverRequest condition"}
reason is required on every path, including lease-expired, and
must meet the deployment reason policy. A
node-fencing re-bind is never anonymous (#687).
One-writer protection: for availability.mode: Autonomy units,
confirmFenced: true is required (400 Bad Request otherwise). The
caller certifies the current node is powered off or disconnected from the
field network, and the confirmation is captured on the AuditRecord. For
Failover-mode units the request proceeds once the runtime's control
lease has expired (it has self-fenced). confirmFenced overrides for
planned maintenance while the lease is still held. If the target already
hosts another unit's runtime, the co-location guard rejects with
409 Conflict unless allowColocation: true.
The gateway validates synchronously for UX, then hands the request to the
physical operator via the dcs.io/failover-to annotation family. The
operator re-validates authoritatively, fences, force-deletes the old
runtime pod, recreates it pinned to the target, and reports the outcome on
the unit's FailoverRequest status condition. The control program is
redeployed from the control-plane source of truth (no hostPath
migration). Recovery is an ISA-88 Restart. Both the gateway request and
the operator's execution emit AuditRecords (21 CFR Part 11).
The unit list/detail response (UnitDTO) surfaces the last outcome as
failoverRequest ({type, status, reason, message, lastTransitionTime},
mirroring the condition, and absent when no failover was ever requested).
Clients can therefore show asynchronously rejected or blocked requests
(e.g. LeaseStillHeld, NoFreeTarget, UnitRunning) alongside
availabilityMode and runtimeBinding.
runtimeBinding.nodeDisplayName carries the bound node's
dcs.io/display-name annotation, resolved at read time and absent when the
node has none. It is the same presentation name the Servers surface and the
failover targets response render, so the Unit detail names the chassis the way
the rest of the product does. runtimeBinding.node stays the identity.
runtimeBinding.leaseState is empty between a re-bind and the first successful
renewal on the new chassis. That window means the lease has not been
established yet. That is a different claim from a unit that holds no lease at
all, so clients render it as Pending for a Failover-mode unit.
status.terminalStopArming is the operator's record of one runtime
incarnation's terminal-stop arming during a live SiteOutage ({outage, podUID,
restartCount, armed, message}, absent when no sequence is stopping the site).
The arming itself is never persisted: it lives in the runtime's memory and dies
with the process, because a runtime that comes back is by definition not
terminally stopped (ADR 0009). What this records is which incarnation was armed
and what the runtime answered. A runtime that restarts mid-sequence is armed
again, and one that could not be is named in the outage's unitsUnarmed,
where nothing counts it as safed.
Edge-local holding (ADR 0008)¶
Two spec fields arm a sequenced safe-state response that the unit
runtime can execute locally during a control-plane partition (ADR 0008).
The legacy behavior, freezing outputs at their last value, is what
arming replaces:
spec.safeStateChart(SFCChart, optional) — the unit's baseline safe-state chart, armed at the edge whenever no phase is active. The runtime embeds the SFC engine and runs this chart against the unit's own control-module tag space, driving a deliberate ordered safe state (e.g. close the binder valve, then stop the agitator). It writes FB input variables only. The FB scan stays the sole driver writer and device interlocks (ADR 0007) still sit underneath. When a phase is running, the phase'sholdingChartis armed in preference to this baseline. Unset preserves the legacy frozen-output behavior on partition.spec.availability.holdGraceSeconds(int, 1–3600, Autonomy only) — how long the edge tolerates control-plane heartbeat loss before running the armed chart. Defaults to 60 s, mirroring the control plane's phase self-hold grace so the edge and operator converge on Held. In Failover mode the hold trigger isleaseDurationSecondsinstead, and the runtime self-fences after the bounded hold so a standby can take over.
The chart must read and write only this unit's own control-module tags. A chart referencing out-of-edge-scope data cannot be armed at the edge.
MTP Module Services¶
Read a smart Unit's declared VDI/VDE/NAMUR 2658 Blatt 4 module services and the state their process-equipment assembly reports right now (ADR 0045). The route requires a live cluster: it opens an OPC UA session to the module with credentials from a Secret, and neither exists in filesystem authoring mode.
| Method | Path | Permission | Description |
|---|---|---|---|
GET |
/api/v1/sites/{site}/units/{name}/mtp-services |
read |
List the declared MTP services with their live ServiceControl reading |
Response:
{
"unit": "dosing-skid",
"endpoint": "opc.tcp://skid:4840",
"observedAt": "2026-08-10T14:02:11Z",
"reachable": true,
"services": [
{
"name": "Dose",
"description": "Meter a charge",
"serviceNodeID": "ns=4;s=Dose",
"state": "Execute",
"stateWord": 64,
"enabledCommands": ["Hold", "Abort"],
"commandEnWord": 288,
"currentProcedure": {"id": 2, "name": "Gentle", "declared": true},
"procedures": [
{"id": 1, "name": "Fast", "selfCompleting": true, "declared": true},
{"id": 2, "name": "Gentle", "declared": true}
]
}
]
}
The reading is taken on demand. A module's state is never mirrored into a CRD
status (ADR 0001), so there is nothing cached to serve. Each request reads
StateCur, CommandEn and (when the declaration exposes it) ProcedureCur
in a single OPC UA Read, over a session the gateway holds between requests so a
polling client is not a handshake per poll.
enabledCommands is the module's CommandEn word: the commands it will accept
at this instant, which is the only admissible gate on commanding it. A module
may withhold any structurally-valid command under its own interlock, so a
client reasons from this list. The state model gates nothing here. An empty
list is a normal reading for a transient state.
state is empty when the StateCur word the module served is not exactly one
defined state of the Table 14 coding. That is a real reading and no failure,
and stateWord carries the integer that arrived. The usual cause is a
declared address pointing at another node, and the number is what identifies it.
commandEnWord is present on the same footing.
currentProcedure.declared is false when the module reports a ProcedureID
the Unit's declaration does not carry. Procedure identity comes from the
vendor's engineering data and never from the server, so that mismatch is
engineering-data drift and is reported as the bare id. Id 0 is the standard's
"no procedure selected" and is a real answer.
reachable is false when the module could not be read at all (a connect
failure, an unsupported serviceBinding.driver, or a credentials Secret the
binding names and the cluster does not have), and error carries the reason.
Every declared service is still listed: the declaration is a fact about the
Unit, the reading is a fact about right now, and an empty list would say the
Unit has no services. A response with no reading carries no observedAt
either. error on an individual service is that service's own failure, which
leaves every other service on the module unaffected. procedureError is a
procedure word that could not be read on a service whose state read fine.
The route reads and commands nothing. Driving a module service is done from
phase logic through the MTP_COMMAND builtin (see
Structured Text), which gates on the same CommandEn
word and leaves an AuditRecord for every attempt.
Interlock Bypass¶
Set or clear a first-class, time-boxed device-interlock bypass on an output
block of a ControlModule (ADR 0010). Both routes require a live cluster
(they reach the unit-runtime pod) and the interlock:bypass permission
(engineer/admin by default).
| Method | Path | Permission | Description |
|---|---|---|---|
POST |
/api/v1/sites/{site}/controlmodules/{cm}/interlock/bypass |
interlock:bypass |
Suppress an output block's interlock for a fixed duration |
DELETE |
/api/v1/sites/{site}/controlmodules/{cm}/interlock/bypass |
interlock:bypass |
Clear an active bypass, re-arming the interlock immediately |
Set request:
{"block": "outlet-valve", "durationSeconds": 900, "reason": "manual stroke test during PM"}
reason is required and must meet the deployment reason
policy. A protection override is never
anonymous. The
bypass suppresses the entire trip (both the wired ILCK input and the
interlockAddress device-read source). While bypassed the block writes its
commanded value even when the interlock condition is present.
Set response:
{"ok": true, "block": "outlet-valve", "until": "2026-06-16T14:32:00Z"}
Clear request:
{"block": "outlet-valve", "reason": "stroke test complete"}
The deadline is enforced in the FB scan on the edge node. The bypass
auto-expires and the interlock re-arms on its own, even during a
control-plane partition. A runtime restart or hot-swap re-arms it too (the
safe direction). The block exposes ILCK_BYPASSED (true while bypassed) and
ILCK_ACTIVE (true while forcing the safe value) for faceplates and
AlarmDefinitions. The gateway records an AuditRecord (action: Command,
category: interlock-bypass) on every set and clear, carrying the user, the
reason, and (on set) the expiry timestamp that bounds the authorized
window (21 CFR Part 11). See ADR 0010.
Mode¶
| Method | Path | Description |
|---|---|---|
POST |
/api/v1/sites/{site}/mode |
Set the ISA-88 mode on a resource |
Request:
{"kind": "Procedure", "name": "wet-granulation", "mode": "Semi-Automatic"}
Response:
{"ok": true, "kind": "Procedure", "name": "wet-granulation", "mode": "Semi-Automatic"}
Valid procedural kinds: Procedure, UnitProcedure, Operation, Phase. Modes: Automatic, Semi-Automatic, Manual
Valid equipment kinds: Unit, ControlModule. Modes: Automatic, Manual
The gateway sets the dcs.io/mode annotation on the target resource. The controller processes it and updates status.mode. In Semi-Automatic mode, sequencing controllers auto-pause at step transitions for operator confirmation (Resume to advance). In Manual mode, children are not auto-started. The operator must issue Start commands explicitly.
Mode changes are rejected with 409 Conflict while the target is in a short-lived transitional state (Pausing, Holding, Restarting, Stopping, Aborting, Resetting). Retry once the state settles. A name that does not exist is rejected with 404 Not Found.
Batches¶
| Method | Path | Description |
|---|---|---|
GET |
/api/v1/sites/{site}/batches |
List batches |
POST |
/api/v1/sites/{site}/batches |
Create a batch |
POST |
/api/v1/sites/{site}/batches/preflight |
Dry-run readiness check for a prospective batch |
GET |
/api/v1/sites/{site}/batches/{name} |
Get batch detail |
PUT |
/api/v1/sites/{site}/batches/{name} |
Update a batch |
DELETE |
/api/v1/sites/{site}/batches/{name} |
Delete a batch (blocked with 403 in production mode) |
GET |
/api/v1/sites/{site}/batches/{name}/events |
Get batch events |
GET |
/api/v1/sites/{site}/batches/{name}/messages |
Get operator messages from all phases |
GET |
/api/v1/sites/{site}/batches/{name}/timeline |
Get execution timeline |
GET |
/api/v1/sites/{site}/batches/{name}/record |
Get consolidated production record (JSON/XML) |
Preflight (POST .../batches/preflight): accepts {"masterRecipeRef": "...", "formulaRef": "...", "requestedUnits": {...}} and returns three independent check results, plus a top-level ok flag: recipe validation (approved, no validation errors), runtime readiness (every candidate unit has a ready runtime pod), and allocation feasibility. Nothing is created. The UI uses this to gate the Create button.
In production operating mode, DELETE is blocked with 403 Forbidden. Batch execution records are retained per 21 CFR Part 11.
Create request:
{
"batchID": "BATCH-2026-001",
"masterRecipeRef": "chemical-mix-cell1",
"description": "First production batch",
"batchSize": "500",
"formulaRef": "500kg Batch",
"priority": 10,
"parameterOverrides": [
{"name": "targetTemp", "value": "90"}
],
"requestedUnits": {"mixing": "reactor-1"}
}
Batch detail response (BatchDTO):
{
"name": "batch-xyz",
"batchID": "BATCH-2026-001",
"masterRecipeRef": "chemical-mix-cell1",
"phase": "Running",
"state": "Running",
"priority": 10,
"startTime": "2026-02-18T10:00:00Z",
"allocatedUnits": [
{"unitName": "reactor-1", "capability": "mixing", "allocatedAt": "2026-02-18T10:00:00Z"}
],
"validCommands": ["Stop", "Hold", "Abort"],
"eventCount": 5
}
Events response:
[
{"type": "Info", "reason": "AllocatedUnits", "message": "Allocated 1 unit(s)", "timestamp": "2026-02-18T10:00:00Z"}
]
Messages response (operator messages emitted by MESSAGE() calls in Structured Text):
[
{"phaseName": "dry-mix", "unitRef": "granulator-1", "text": "Starting dry mix at 200 RPM", "timestamp": "2026-02-18T10:01:05Z"},
{"phaseName": "dry-mix", "unitRef": "granulator-1", "text": "Dry mix complete", "timestamp": "2026-02-18T10:06:05Z"}
]
Batch Records¶
Batch production records are auto-created when a batch reaches a terminal state (Complete, Stopped, Failed, Aborted). They track finalization status for 21 CFR Part 11 compliance.
| Method | Path | Description |
|---|---|---|
GET |
/api/v1/sites/{site}/batchrecords |
List batch records |
GET |
/api/v1/sites/{site}/batchrecords/{name} |
Get batch record detail |
POST |
/api/v1/sites/{site}/batchrecords/{name}/finalize |
Finalize with e-signature |
POST |
/api/v1/sites/{site}/batchrecords/{name}/review |
Record a review decision with e-signature |
POST |
/api/v1/sites/{site}/batchrecords/{name}/comments |
Add a review comment |
GET |
/api/v1/sites/{site}/batches/{name}/record |
Get consolidated production record |
Review request (POST .../review): {"decision": "Approved", "comments": "...", "reason": "..."}. decision must be Approved or Rejected, and reason is required. Comment request (POST .../comments): {"text": "..."} (max 4096 characters).
Batch record detail response:
{
"name": "record-batch-xyz",
"batchName": "batch-xyz",
"batchID": "BATCH-2026-001",
"recipeName": "chemical-mix-cell1",
"recipeGeneration": 3,
"outcome": "Complete",
"phase": "PendingReview",
"startTime": "2026-02-18T10:00:00Z",
"completionTime": "2026-02-18T14:30:00Z"
}
Consolidated production record (GET .../batches/{name}/record):
Assembles data from the Batch CR, AuditRecord CRDs, and historian time-series into a single response. Use ?format=xml or Accept: application/xml for BatchML-aligned XML export.
Finalize request:
{"reason": "Batch reviewed and approved for release"}
Finalize response:
{
"name": "record-batch-xyz",
"phase": "Finalized",
"actionBy": "Jane Smith (jane@example.com)",
"actionAt": "2026-02-19T09:00:00Z",
"reason": "Batch reviewed and approved for release",
"signature": "a1b2c3d4..."
}
Recipes¶
Master Recipes¶
| Method | Path | Description |
|---|---|---|
GET |
/api/v1/sites/{site}/recipes/master |
List master recipes |
POST |
/api/v1/sites/{site}/recipes/master |
Create a master recipe |
GET |
/api/v1/sites/{site}/recipes/master/{name} |
Get master recipe detail |
PUT |
/api/v1/sites/{site}/recipes/master/{name} |
Update a master recipe (Draft/Rejected only, 409 otherwise) |
DELETE |
/api/v1/sites/{site}/recipes/master/{name} |
Delete a master recipe |
Recipe Lifecycle (ISA-88 Table 7)¶
Master recipes move through the ISA-88 lifecycle Draft → Approved → Released → Effective → Withdrawn (a Draft may also be Rejected). Each transition requires the Supervise permission, a fresh authentication token (issued within the last 5 minutes, with 401 and reauthentication_required otherwise), and a request body {"reason": "..."} with a non-empty reason. The transition is recorded as an AuditRecord carrying an electronic signature over the recipe spec (21 CFR Part 11 §11.200).
| Method | Path | Description |
|---|---|---|
POST |
/api/v1/sites/{site}/recipes/master/{name}/approve |
Draft → Approved |
POST |
/api/v1/sites/{site}/recipes/master/{name}/reject |
Draft → Rejected |
POST |
/api/v1/sites/{site}/recipes/master/{name}/release |
Approved → Released |
POST |
/api/v1/sites/{site}/recipes/master/{name}/activate |
Released → Effective |
POST |
/api/v1/sites/{site}/recipes/master/{name}/withdraw |
Effective → Withdrawn |
A transition attempted from any other phase returns 409 Conflict. The response carries name, phase, actionBy, actionAt, reason, and the e-signature signature digest (same shape as the batch-record finalize response).
New Recipe Version¶
Versions of a product are separate MasterRecipe resources (an Effective version keeps running production while its successor sits in Draft), but neither the version number nor the resource name is hand-composed: the server increments spec.version across the product family (all recipes sharing spec.productID in the site) and derives metadata.name from the product ID and new version.
| Method | Path | Description |
|---|---|---|
POST |
/api/v1/sites/{site}/recipes/master/{name}/new-version |
Clone this recipe into the next version of its product, as a new Draft |
Requires the Engineer permission. The optional body {"bump": "major" | "minor" | "patch"} selects the incremented segment (default major). Returns 201 with the new recipe DTO, 400 when the source recipe declares no spec.productID, and 409 when the derived name is already taken. The plain create and update endpoints (and generic /api/v1/apply) reject a spec.productID + spec.version pair that already exists in the namespace with 409 Conflict, so a product's version always identifies exactly one recipe.
Recipe Bindings (cmRole matrix)¶
The binding matrix shows how every control-module role declared by the phase templates a recipe reaches resolves against the recipe's target units. It is the same check batch instantiation enforces, exposed for authoring and commissioning (dcs recipe check-bindings / dcs unit check-bindings).
| Method | Path | Description |
|---|---|---|
GET |
/api/v1/sites/{site}/recipes/master/{name}/bindings |
Binding matrix of the recipe's cmRole requirements vs its target units |
GET |
/api/v1/sites/{site}/units/{name}/bindings |
Inverted view: every phase template with cmRoles checked against this unit |
Each row carries step, phaseTemplate, role, requiredType, unit, boundCM, cmModuleType, and, when the binding is unusable, an issue (Unmapped, CMNotFound, TypeMismatch) plus a human-readable message. The recipe response also returns notes for steps whose unit is chosen at batch time, and a total violations count.
Recipe Revisions¶
Every approved spec change produces a numbered RecipeRevision snapshot. Reverting copies the snapshot back into the recipe spec and resets the phase to Draft. The recipe must go through a new approval cycle.
A revert is therefore an edit of the whole recipe, and it refuses the phases in which the recipe is out in the plant. It is accepted while the recipe is Draft, Rejected, Approved or Withdrawn, and returns 409 Conflict while it is Released or Effective. ISA-88.00.03-2003 Table 7 describes those two as distributed and as available for use, so replacing their content in place is the edit PUT /api/v1/sites/{site}/recipes/master/{name} already refuses. Author the change as a new version instead. The set is wider than the update route's Draft and Rejected because a RecipeRevision is only written on approval, so a revert that required Draft could never be called once.
A revert is also refused while a batch still holds the recipe, which the phase check does not cover on its own. A recipe can be withdrawn while a batch runs on it. A running batch re-reads its MasterRecipe on every reconcile, and a batch that has finished without its BatchRecord yet is inside the window where the record freezes the recipe spec as the as-run snapshot. Reverting inside that window would record content that never ran as the content that did.
| Method | Path | Description |
|---|---|---|
GET |
/api/v1/sites/{site}/recipes/master/{name}/revisions |
List revisions (newest first) |
GET |
/api/v1/sites/{site}/recipes/master/{name}/revisions/diff |
Field-level diff between two revisions (?v1=N&v2=M, both required) |
GET |
/api/v1/sites/{site}/recipes/master/{name}/revisions/{revision} |
Get a single revision snapshot |
POST |
/api/v1/sites/{site}/recipes/master/{name}/revisions/{revision}/revert |
Revert the recipe spec to this revision (409 while Released or Effective, or while a batch still holds the recipe) |
Revert requires Supervise (or admin) group membership, a fresh token, and a {"reason": "..."} body. The action is audited, and so is a refused attempt.
Recipe Formulas (sub-resource)¶
Formulas are embedded in MasterRecipe specs. These sub-resource endpoints provide individual formula CRUD without replacing the entire recipe.
MasterRecipe formulas (site-scoped):
| Method | Path | Description |
|---|---|---|
GET |
/api/v1/formulas |
List formulas across all recipes (aggregate, cluster-wide) |
GET |
/api/v1/sites/{site}/recipes/master/{name}/formulas |
List formulas |
POST |
/api/v1/sites/{site}/recipes/master/{name}/formulas |
Add a formula (Draft/Rejected only) |
GET |
/api/v1/sites/{site}/recipes/master/{name}/formulas/{formulaName} |
Get a formula |
PUT |
/api/v1/sites/{site}/recipes/master/{name}/formulas/{formulaName} |
Update a formula (Draft/Rejected only) |
DELETE |
/api/v1/sites/{site}/recipes/master/{name}/formulas/{formulaName} |
Delete a formula (Draft/Rejected only) |
FormulaParameter fields (ISA-88 Part 2 Table 10 / Table 41):
| Field | Type | Description |
|---|---|---|
name |
string | Parameter name (required) |
value |
string | Parameter value (required) |
engineeringUnit |
string | Unit of measurement (e.g., "degC", "kg", "RPM") |
description |
string | Human-readable parameter description |
lowValueLimit |
string | Smallest allowed value (Table 41) |
highValueLimit |
string | Largest allowed value (Table 41) |
lowTolerance |
string | Largest downward deviation from value (Table 41) |
highTolerance |
string | Largest upward deviation from value (Table 41) |
scaled |
bool | Whether this value scales with batch size (Table 10) |
Control Recipes¶
| Method | Path | Description |
|---|---|---|
GET |
/api/v1/sites/{site}/recipes/control |
List control recipes |
GET |
/api/v1/sites/{site}/recipes/control/{name} |
Get control recipe detail |
DELETE |
/api/v1/sites/{site}/recipes/control/{name} |
Delete a control recipe (blocked with 403 in production mode) |
Control recipes are created automatically by the batch controller and cannot be created via the API.
Other Information (ISA-88 Clause 6)¶
Both recipe levels (Master, Control) support an optional spec.otherInformation object containing supplementary data per ISA-88 Clause 6:
safetyNotes— handling precautions and hazard informationregulatoryNotes— compliance requirements and documentation referencesoperatorInstructions— instructions for manual stepsdocumentReferences[]— references to external documents, each withname,type(SDS, SOP, Specification, Diagram), optionalurlandid
The batch controller copies otherInformation from the MasterRecipe to the ControlRecipe at instantiation time.
Recipe SFC Step Fields¶
Both recipe levels support spec.procedure.chart.steps[] containing RecipeSFCStep objects:
| Field | Description |
|---|---|
templateRef |
References a PhaseTemplate, OperationTemplate, or UnitProcedureTemplate (equipment-specific procedural model). |
templateKind |
Specifies what templateRef points to: PhaseTemplate, OperationTemplate, UnitProcedureTemplate, or ProcedureTemplate. Enables deterministic expansion during batch instantiation. |
stepLevel |
ISA-88 procedural hierarchy level: UnitProcedure, Operation, or Phase. |
targetCapability |
Required equipment capability (e.g., "mixing"). Resolved to a specific unit via unit bindings at batch time. |
parameterBindings |
Maps step parameter names to recipe-level parameter names for audit traceability. |
subChart |
Nested SFC chart for hierarchical procedures. |
Recipe controllers set an ISA88Compliance status condition (True/False) indicating whether the recipe's procedure follows the correct model for its level.
Procedural¶
| Method | Path | Description |
|---|---|---|
GET |
/api/v1/sites/{site}/procedures |
List procedures |
POST |
/api/v1/sites/{site}/procedures |
Create a procedure |
GET |
/api/v1/sites/{site}/procedures/{name} |
Get procedure detail |
PUT |
/api/v1/sites/{site}/procedures/{name} |
Update a procedure |
DELETE |
/api/v1/sites/{site}/procedures/{name} |
Delete a procedure |
GET |
/api/v1/sites/{site}/unitprocedures |
List unit procedures |
POST |
/api/v1/sites/{site}/unitprocedures |
Create a unit procedure |
GET |
/api/v1/sites/{site}/unitprocedures/{name} |
Get unit procedure detail |
PUT |
/api/v1/sites/{site}/unitprocedures/{name} |
Update a unit procedure |
DELETE |
/api/v1/sites/{site}/unitprocedures/{name} |
Delete a unit procedure |
GET |
/api/v1/sites/{site}/operations |
List operations |
POST |
/api/v1/sites/{site}/operations |
Create an operation |
GET |
/api/v1/sites/{site}/operations/{name} |
Get operation detail |
PUT |
/api/v1/sites/{site}/operations/{name} |
Update an operation |
DELETE |
/api/v1/sites/{site}/operations/{name} |
Delete an operation |
GET |
/api/v1/sites/{site}/phases |
List phases |
POST |
/api/v1/sites/{site}/phases |
Create a phase |
GET |
/api/v1/sites/{site}/phases/{name} |
Get phase detail |
PUT |
/api/v1/sites/{site}/phases/{name} |
Update a phase |
DELETE |
/api/v1/sites/{site}/phases/{name} |
Delete a phase |
Procedural resources are normally created automatically by the batch controller during recipe instantiation. The POST/PUT endpoints exist for engineering and authoring use. In production operating mode, the DELETE endpoints are blocked with 403 Forbidden (21 CFR Part 11 record retention). ISA-88 state commands cannot be sent to a batch-owned procedural element directly. Issue them through the owning Batch via the command endpoint. The root of an ad-hoc execution is the exception: it has no owning Batch, so the same endpoint accepts it by name. Whether a Procedure, UnitProcedure, or Operation starts its children one at a time or together is decided by its own spec.chart: a chart carrying a SimultaneousDiverge divergence starts them all together, and any other chart runs them in order. A Sequential/Parallel executionMode field appears in some older material. It was removed from these kinds and nothing reads it. An element the batch controller created never carries a divergence. Instantiation builds each child chart as a sequence (#1698, #1703). All procedural resources also support a mode field (Automatic, Semi-Automatic, Manual) per ISA-88 Clause 7.3.2 (see the Mode section for details).
Parameter Propagation¶
Per ISA-88 Clause 8.7.2, each procedural level carries its own formula parameters. The batch controller resolves parameters hierarchically during procedural tree creation:
- UnitProcedure:
spec.parameters(formal interface from UnitProcedureTemplate) andspec.parameterValues(resolved runtime values from the recipe formula). - Operation:
spec.parametersandspec.parameterValues, resolved from the parent UnitProcedure's scoped values merged with the OperationTemplate's formal interface. - Phase:
spec.parametersandspec.parameterValues, resolved from the parent Operation's scoped values merged with the PhaseTemplate's formal interface.
Parameters cascade: recipe formula → UnitProcedure → Operation → Phase. Templates without declared parameters pass values through unscoped for backward compatibility. All levels also have status.outputValues for runtime audit recording (21 CFR Part 11).
Parameters and Process Outputs¶
ISA-88 Part 1 §6.3.3 distinguishes Process Parameters (recipe-commanded inputs) from Process Outputs (values the chart records). The procedural API exposes both as separate lists on Phase / Operation / UnitProcedure specs and their templates.
ParameterSpec: recipe-commanded input. The chart reads it as an ST variable, and there is no schema-level "readback". Declare a separate OutputSpec if the BPR should record a measured counterpart.
OutputSpec: anything the chart writes for the BPR. ISA-88 Part 1 §6.3.3 "Process Outputs". Fields:
| Field | Description |
|---|---|
name |
Output identifier in the BPR. The chart's ST writes to this name like any declared variable. Must be unique across spec.parameters[] and spec.outputs[] within a single template. |
type |
IEC 61131-3 data type (BOOL, INT, REAL, STRING, TIME). |
unit |
Engineering unit. |
description |
Human-readable description of what the output represents. |
Snapshot semantics. The runtime takes a snapshot of declared output names from the ST environment at phase terminal time. Outputs the chart never assigned to are omitted from the snapshot, and they produce no BPR row. To capture only on successful Complete, only assign in the chart's terminal step. To capture on any terminal state, also assign in the stopping/aborting charts.
BPR rows. Input rows live on BatchRecord.spec.parameterValues[] with recipe-layer provenance (formula-scaled / override / default). Output rows live on BatchRecord.spec.outputs[] with no provenance enum. The chart's ST is the only producer. Phase-level rollups appear on PhaseExecutionRecord.parameterValues[] and PhaseExecutionRecord.outputs[].
See Parameters and process outputs in the Procedural SFC Dialect for authoring patterns. Migrating from the older readback model: run dcs migrate readback <path>.
Operator Prompts¶
When a Phase's SFC chart calls a prompt builtin in Structured Text (PROMPT('message') for acknowledge, PROMPT_CHOICE('message', 'opt-a', 'opt-b') for an enumerated decision, or PROMPT_VALUE('message', min, max, 'unit') for bounded numeric entry), execution pauses and the phase exposes a pendingPrompt field in its detail response carrying the prompt's type and, per type, choices or min/max/unit (bounds as decimal strings). The operator must answer via the prompt-response endpoint before execution continues.
Response validation (ADR 0017): the gateway validates the response against the prompt type, fail-closed. Acknowledge prompts record the fixed sentinel Acknowledged regardless of the submitted text (prompts published by pre-0017 runtimes carry no type and are treated as acknowledge). Choice responses must equal one of the authored options. Value responses must parse as a number within [min, max] and are canonicalized to a plain decimal. Violations return 400 Bad Request.
Prompt lifetime: a prompt belongs to the runtime episode that raised it. When a phase reaches a terminal state (Idle, Complete, Stopped, Aborted) the procedural operator clears pendingPrompt, and the gateway refuses any response submitted against such a phase with 409 Conflict. No electronic signature is recorded, since nothing remains to consume it. Prompts on Held and Paused phases stay answerable, and a Restart or Resume drives the chart straight back into them.
Chart validation: Steps that call any prompt builtin must capture the response in a variable (e.g., ack := PROMPT('...')) and the outgoing transition must include a conditionST that checks it (e.g., ack <> '', cut = 'Cut now', ph >= 6.5). Unconditional transitions from prompt steps are rejected.
| Method | Path | Description |
|---|---|---|
POST |
/api/v1/sites/{site}/phases/{name}/prompt-response |
Acknowledge a pending prompt with e-signature |
Request body:
{
"promptID": "a1b2c3d4e5f6a7b8",
"response": "Acknowledged",
"reason": "Fill level verified visually per SOP 324"
}
The reason field is required for 21 CFR Part 11 electronic signature compliance. The response is signed with HMAC-SHA256 and recorded as an AuditRecord with action PromptAcknowledge.
Pending prompt in phase detail (GET /api/v1/sites/{site}/phases/{name}):
{
"pendingPrompt": {
"id": "a1b2c3d4e5f6a7b8",
"text": "Verify tank fill level is acceptable before starting agitation",
"timestamp": "2026-03-30T14:22:00Z",
"stepName": "confirm-fill"
}
}
SFC Execution Status¶
When a Phase runs an SFC chart, its detail response includes live execution monitoring data in the sfcStatus field. This is polled by the gateway UI to render the live SFC diagram.
Transition states report per-transition condition evaluation results. When a transition condition contains AND-joined sub-expressions, the subConditions array breaks down each operand individually, which is useful for diagnosing which condition is blocking a transition.
{
"sfcStatus": {
"activeSteps": ["mix"],
"completedSteps": ["init"],
"scanCount": 142,
"held": false,
"transitionStates": [
{
"fromStep": "mix",
"toStep": "discharge",
"conditionST": "temp > 75.0 AND timer_done AND valve_open",
"evaluated": true,
"met": false,
"subConditions": [
{"expression": "temp > 75.0", "met": true},
{"expression": "timer_done", "met": true},
{"expression": "valve_open", "met": false}
]
}
],
"variableSnapshot": {
"temp": "72.5",
"timer_done": "true",
"valve_open": "false",
"batch_id": "BATCH-2026-001"
}
}
}
The subConditions array is only populated when the condition contains multiple AND-joined operands. Simple conditions (single expression) omit it.
The variableSnapshot object maps ST variable names to their current string-formatted values during active execution. The gateway UI overlays these as live value badges on the ST code panels in the SFC phase detail view. The snapshot is cleared when execution completes.
Procedural Templates¶
Templates are the reusable procedural building blocks (site-scoped) that recipes and ad-hoc execution reference via templateRef. All four families expose the same CRUD surface.
| Method | Path | Description |
|---|---|---|
GET |
/api/v1/sites/{site}/phasetemplates |
List phase templates |
POST |
/api/v1/sites/{site}/phasetemplates |
Create a phase template |
GET |
/api/v1/sites/{site}/phasetemplates/{name} |
Get phase template detail |
PUT |
/api/v1/sites/{site}/phasetemplates/{name} |
Update a phase template |
DELETE |
/api/v1/sites/{site}/phasetemplates/{name} |
Delete a phase template |
GET |
/api/v1/sites/{site}/operationtemplates |
List operation templates |
POST |
/api/v1/sites/{site}/operationtemplates |
Create an operation template |
GET |
/api/v1/sites/{site}/operationtemplates/{name} |
Get operation template detail |
PUT |
/api/v1/sites/{site}/operationtemplates/{name} |
Update an operation template |
DELETE |
/api/v1/sites/{site}/operationtemplates/{name} |
Delete an operation template |
GET |
/api/v1/sites/{site}/unitproceduretemplates |
List unit procedure templates |
POST |
/api/v1/sites/{site}/unitproceduretemplates |
Create a unit procedure template |
GET |
/api/v1/sites/{site}/unitproceduretemplates/{name} |
Get unit procedure template detail |
PUT |
/api/v1/sites/{site}/unitproceduretemplates/{name} |
Update a unit procedure template |
DELETE |
/api/v1/sites/{site}/unitproceduretemplates/{name} |
Delete a unit procedure template |
GET |
/api/v1/sites/{site}/proceduretemplates |
List procedure templates |
POST |
/api/v1/sites/{site}/proceduretemplates |
Create a procedure template |
GET |
/api/v1/sites/{site}/proceduretemplates/{name} |
Get procedure template detail |
PUT |
/api/v1/sites/{site}/proceduretemplates/{name} |
Update a procedure template |
DELETE |
/api/v1/sites/{site}/proceduretemplates/{name} |
Delete a procedure template |
Alarms¶
Alarms are system-generated ISA-18.2 alarm conditions. They cannot be created or edited via the API. They are raised automatically by the alarm generator controller and by AlarmDefinition rules. DELETE exists solely for engineer/admin cleanup of orphaned alarms.
| Method | Path | Description |
|---|---|---|
GET |
/api/v1/sites/{site}/alarms |
List alarms |
GET |
/api/v1/sites/{site}/alarms/{name} |
Get alarm detail |
POST |
/api/v1/sites/{site}/alarms/{name}/acknowledge |
Acknowledge an alarm |
POST |
/api/v1/sites/{site}/alarms/{name}/shelve |
Shelve an alarm |
POST |
/api/v1/sites/{site}/alarms/{name}/unshelve |
Unshelve an alarm |
DELETE |
/api/v1/sites/{site}/alarms/{name} |
Delete an orphaned alarm (engineer/admin cleanup; 409 while unacknowledged) |
Shelve request:
{"duration": "2h"}
Valid durations: any Go time.ParseDuration string (e.g., 30m, 2h, 8h, 24h).
Shelving requires the alarm to be in state ActiveAcknowledged (ADR 0014): the
alarm must be active and already acknowledged. Other states are rejected with
409 Conflict. Shelving suppresses annunciation system-wide for the duration via
the retained alarm event (shelved / shelvedUntil). The controller
auto-unshelves when the deadline passes.
Unshelving takes no request body and ends the suppression immediately.
Annunciation resumes if the condition is still active. Unshelving an alarm
with no shelve in effect is rejected with 409 Conflict. Unshelving is
recorded in the audit trail, like acknowledge and shelve.
Alarm detail response (AlarmDTO):
{
"name": "alarm-iomodule-granulator-ai-system",
"source": "IOModule/granulator-ai",
"type": "System",
"severity": "Medium",
"state": "ActiveUnacknowledged",
"message": "IOModule granulator-ai is Offline (communication loss)",
"definitionRef": "granulator-ai-offline",
"conditionType": "StateEquals",
"setpoint": "",
"value": "",
"acknowledgedBy": "",
"shelved": false,
"shelvedUntil": ""
}
The definitionRef field links to the AlarmDefinition that generated this alarm (empty for system-generated infrastructure alarms).
conditionType is the AlarmDefinition condition that raised the alarm, served
verbatim, and value is whatever that condition matched on. Together they are
how a consumer tells one kind of alarm from another without reading its name or
its message: a device-health alarm is conditionType: DeviceHealth with an
NE 107 word in value. Both are empty for an alarm no definition created. See
Telling one kind of alarm from another.
shelved is the field to key on for suppression, and it is computed against the
current time exactly as the retained MQTT event computes it. shelvedUntil is
the deadline and is informational: a shelf that has lapsed leaves the deadline
populated until the alarm controller's next reconcile clears it, so reading a
non-empty shelvedUntil as suppression silences an alarm that is annunciating.
The WebSocket and SSE alarm frames carry both fields for the same reason, so a
client recovering over either transport is told the same thing.
Deleting an alarm (through this endpoint, or by deleting the AlarmDefinition that owns it) also corrects what the MQTT transport says about the alarm's source. The alarm controller clears the source's retained event and republishes the alarms that remain on it, so a consumer connecting afterwards is not told about an alarm that no longer exists. See MQTT Telemetry for the tombstone a direct MQTT subscriber sees.
Alarm Definitions¶
AlarmDefinitions are user-configurable alarm rules that monitor equipment state and automatically generate Alarm CRs when conditions are met.
| Method | Path | Description |
|---|---|---|
GET |
/api/v1/sites/{site}/alarmdefinitions |
List alarm definitions |
POST |
/api/v1/sites/{site}/alarmdefinitions |
Create an alarm definition |
GET |
/api/v1/sites/{site}/alarmdefinitions/{name} |
Get alarm definition detail |
PUT |
/api/v1/sites/{site}/alarmdefinitions/{name} |
Update an alarm definition (409 on anything but message while the equipment it watches is executing) |
DELETE |
/api/v1/sites/{site}/alarmdefinitions/{name} |
Delete an alarm definition (409 while any alarm it owns is unacknowledged) |
Query parameters (list):
source_kind— filter by source kind (IOModule,Unit,ControlModule)source_name— filter by source name
Create request:
{
"name": "granulator-ai-fault",
"sourceKind": "IOModule",
"sourceName": "granulator-ai",
"conditionType": "StateEquals",
"stateValue": "Fault",
"type": "Equipment",
"severity": "High",
"message": "IOModule granulator-ai is in Fault state",
"priority": 2,
"exceptionAction": "Hold"
}
Example with a debounced boolean condition (discrete-valve feedback mismatch):
{
"name": "reactor-wfi-valve-mismatch",
"sourceKind": "ControlModule",
"sourceName": "reactor-wfi-valve",
"conditionType": "TagTrue",
"tagName": "MISMATCH",
"debounceSeconds": 3,
"type": "Equipment",
"severity": "High",
"message": "Reactor WFI valve CMD/FB mismatch",
"priority": 2,
"exceptionAction": "Hold"
}
The optional exceptionAction field (Hold, Stop, Abort) auto-issues the specified ISA-88 command to affected batch procedures when the alarm triggers. Omit it for an annunciation-only rule. On update, an explicit "exceptionAction": "" clears one that was set.
Update request: same fields as create (except name).
Response (AlarmDefinitionDTO):
{
"name": "fd1-vacuum-loss",
"sourceKind": "ControlModule",
"sourceName": "fd1-pressure-sensor",
"conditionType": "TagHigh",
"tagName": "PV",
"threshold": "913",
"deadband": "5",
"debounceSeconds": 10,
"type": "Equipment",
"severity": "High",
"message": "Filter-dryer vacuum loss",
"priority": 2,
"enabled": true,
"exceptionAction": "Hold",
"armingGate": {
"phaseTemplateRef": "vacuum-dry",
"unitRef": "FD1",
"states": ["Running", "Holding", "Restarting"]
},
"armed": false,
"gatedBy": "",
"suppressedBy": "NotArmed",
"activeAlarmRef": ""
}
Supported condition types:
StateEquals(state field matching)TagHigh,TagLow,TagHighHigh,TagLowLow(process value threshold alarms)TagDeviation(deviation from setpoint)TagTrue,TagFalse(boolean tag state, for signals like discrete-valveMISMATCH)
Tag-based conditions require sourceKind: ControlModule and read live values from the unit runtime. The setpoint field is required for TagDeviation conditions. The deadband field provides value-based hysteresis. The optional debounceSeconds field adds one-sided time-based hysteresis, so the alarm only fires after the condition has been continuously met for the specified duration. Clearing is always immediate. debounceSeconds is essential for boolean fault signals (valve travel time) and noisy analog inputs. See Tuning debounce for real equipment for how to pick a value.
Reaction time: StateEquals alarms are watch-driven and fire within one reconcile of the source status update (typically < 1 s). Tag-based conditions are polled at a 10 s cadence (tagEvalInterval in the controller). Their end-to-end reaction time is 0–10 s poll-phase + debounceSeconds, typically 10–60 s. The 10 s poll is a documented contract with no tunable behind it. Sub-second process-value interlocks belong on a phase SFC transition guard at priority: -1. An AlarmDefinition is the wrong home for them. See Alarm Management → Reaction time and ADR-0002 for the decision record.
Phase-scoped arming gate: The optional spec.armingGate (set on the CR, and not yet writable through the REST create/update endpoints) suppresses condition evaluation unless a Phase matching phaseTemplateRef/unitRef is currently in one of the gate's allowed ISA-88 states (states defaults to [Running], and the CRD enum rejects Idle/Complete/Stopped/Aborted). The response surfaces armed, gatedBy, and suppressedBy (Disabled or NotArmed) so HMI/CLI clients can distinguish ISA-18.2's three "not firing" axes: out-of-service, shelved, and state-based suppression. See Phase-Scoped Alarm Arming for the decision record.
Coordination Blocks¶
CoordinationBlocks implement ISA-88 coordination control (Clause 7.2, distinct from basic and procedural control), using Signal mode for cross-unit signal exchange. The CoordinationBlock controller runs in the batch-operator.
CRD: control.dcs.io/v1alpha1, kind CoordinationBlock (namespaced)
| Field | Type | Description |
|---|---|---|
spec.mode |
enum | Coordination type: Signal |
spec.batchRef |
string | Name of the owning Batch |
spec.timeoutSeconds |
int32 | Maximum wait time (0 = no timeout) |
spec.description |
string | Human-readable description |
spec.signal.signalName |
string | Unique signal name within batch scope |
spec.signal.producer |
SignalEndpoint | Resource that raises the signal |
spec.signal.consumers |
[]SignalEndpoint | Resources that wait for the signal |
spec.signal.condition |
string | Optional expression for signal raise condition |
status.phase |
enum | Lifecycle: Idle, Active, Satisfied, TimedOut, Failed |
status.signalRaised |
bool | Whether the producer has raised the signal |
status.signalRaisedAt |
time | When the signal was raised |
status.consumerStatuses |
[]status | Per-consumer notification tracking |
status.satisfiedAt |
time | When coordination completed |
SignalEndpoint fields: kind (Phase/Operation/UnitProcedure), name, namespace (optional), unitRef (informational).
Write surface: CoordinationBlocks have no per-kind REST route. They are authored in YAML and reach the gateway through Generic Apply, which is also the path dcs restore crds posts a backup's documents to. A Signal-mode block without spec.signal is refused with 400 Bad Request: the CRD schema cannot express that conditional requirement, and the controller would otherwise admit the block and drive it straight to Failed.
Generic Apply¶
| Method | Path | Description |
|---|---|---|
POST |
/api/v1/apply |
Create or update a resource from a Kubernetes-style JSON manifest |
Accepts a DCS CRD manifest in JSON format (the same shape as kubectl apply, JSON-encoded). The apiVersion and kind fields determine the resource type. For most kinds the semantics are create-or-update: if the named resource exists, its spec is replaced. Otherwise it is created. This enables the round-trip workflow dcs get ... -o yaml > resource.yaml && dcs apply -f resource.yaml.
Authorization: every kind carries the cumulative engineer permission (the same tier as the per-kind REST create/update endpoints), so engineers, supervisors, and admins all qualify. Applying a resource is authorized identically via the UI, the REST endpoints, and dcs apply. The same action carries the same gate on every transport, and proposing a change via kind: ChangeRequest shares the gate of POST /api/v1/sites/{site}/changerequests.
Accepted kinds include: Enterprise, Site, Area, ProcessCell, Unit, ControlModule, ControlModuleTemplate, IOModule, Controller, SimulationPreset, MasterRecipe, Batch, ControlProgram (alias FBNetwork), FunctionBlockType (alias FBBlockType), CoordinationBlock, Procedure, UnitProcedure, Operation, Phase, SyncBarrier, PhaseTemplate, OperationTemplate, UnitProcedureTemplate, ProcedureTemplate, Alarm, AlarmDefinition, and ChangeRequest. Unsupported kinds return 400 Bad Request. A kind the gateway recognises but deliberately will not apply (a controller-derived or read-only record that a backup captures as evidence) returns 422 Unprocessable Entity with the reason. dcs restore crds reports that as a skip, and the restore does not fail on it.
kind: ChangeRequest is special-cased and mirrors the 21 CFR Part 11 behavior of POST /api/v1/sites/{site}/changerequests:
- Create-only — applying over an existing ChangeRequest returns
409 Conflict. Every post-creation mutation must flow through the approve / reject / withdraw endpoints so each change carries a freshly stamped, verifiable signature. spec.signaturesmust be empty in the manifest (400otherwise) — the gateway stamps the proposed electronic signature server-side, HMAC-bound to the gateway key store.- When authentication is enabled, a fresh token is required (issued within the last 5 minutes, with
401andreauthentication_requiredotherwise). spec.target(apiVersion,kind,name) andspec.reasonare required.spec.operationdefaults toUpdateand must be one ofCreate,Update,Delete.- On success apply returns
200 OK(consistent with every other apply kind), whereas the REST create endpoint returns201 Created. The response body is the same ChangeRequest DTO in both cases.
Change Control (21 CFR Part 11)¶
ChangeRequests implement the propose-approve workflow for controlled changes. A ChangeRequest lives in its target's namespace. The list endpoint walks every namespace the caller can read, so it is cluster-scoped.
| Method | Path | Description |
|---|---|---|
GET |
/api/v1/changerequests |
List ChangeRequests across all sites (?phase= comma-separated filter) |
POST |
/api/v1/sites/{site}/changerequests |
Propose a change (fresh token required; gateway stamps the proposed e-signature) |
GET |
/api/v1/changerequests/{namespace}/{name} |
Get ChangeRequest detail (includes current target object for diffing, best-effort) |
POST |
/api/v1/changerequests/{namespace}/{name}/approve |
Approve (supervisor/admin, fresh token, e-signature; the proposer cannot approve their own request) |
POST |
/api/v1/changerequests/{namespace}/{name}/reject |
Reject (supervisor/admin, fresh token, e-signature) |
POST |
/api/v1/changerequests/{namespace}/{name}/withdraw |
Withdraw (original author or admin only) |
GET |
/api/v1/system/changerequest-backends |
Which backend an unspecified spec.backend resolves to, and the branch git-pr targets. Answers from the deployment's configuration, so it needs no live cluster |
Backend advertisement: GET /api/v1/system/changerequest-backends returns {"defaultBackend": "git-pr", "gitBranch": "main"}. Both fields are deployment configuration mirrored from the reconciler's own flags, and the response never carries the repository or a credential. defaultBackend is empty when the deployment did not configure one, and gitBranch is omitted unless git-pr is configured. The Promote UI uses it to tell a proposer what the selected backend will do before the signature is collected (#1383). See Promote a recipe.
Create request: {"target": {"apiVersion": "...", "kind": "...", "name": "..."}, "operation": "Update", "desiredObject": {...}, "reason": "...", "gitFilePath": "..."}. target and reason are required. operation defaults to Update (Create/Update/Delete). gitFilePath is optional and names the repo-relative file declaring the target for the git-pr backend's in-place edit. When omitted, that backend resolves the declaring file by searching the repository. Approve, reject, and withdraw take {"reason": "..."}. Each action stamps a server-side electronic signature. The changecontrol reconciler applies the change once the required signature meanings are present.
Segregation of duties: the user who stamped the proposed signature cannot also supply the approved one. Approve returns 403 Forbidden for the proposer, and the reconciler independently refuses to count a self-approval toward the required meanings. This holds for every backend (direct-apply, git-pr, in-cluster-queue). Deployments can opt out by setting spec.requireDistinctApprover: false on the active ChangeControlPolicy (default true, and a missing policy object also enforces).
Read-only verification endpoints over the change-control audit chain (§11.10(k)):
| Method | Path | Description |
|---|---|---|
GET |
/api/v1/sites/{site}/change-control/history |
Admitted-mutation history for one resource (?kind= and ?name= required) |
GET |
/api/v1/change-control/verify |
Chain-integrity report across all sites (?site= to limit) |
MES Integration (ISA-95 Level 3/4)¶
Machine-to-machine API for MES systems. Supports API key authentication (Authorization: ApiKey <key>) in addition to OIDC Bearer tokens. All GET endpoints support B2MML XML output via ?format=b2mml or Accept: application/xml.
API Key Management¶
| Method | Path | Description |
|---|---|---|
POST |
/api/v1/mes/apikeys |
Create a new MES API key (admin-only) |
GET |
/api/v1/mes/apikeys |
List API keys (admin-only, no secret material) |
DELETE |
/api/v1/mes/apikeys/{name} |
Revoke an API key (admin-only) |
Product Definition (ISA-95 Category 1)¶
| Method | Path | Description |
|---|---|---|
GET |
/api/v1/mes/sites/{site}/product-definitions |
List effective recipes as ISA-95 product definitions |
GET |
/api/v1/mes/sites/{site}/product-definitions/{name} |
Get a single product definition |
Production Capability (ISA-95 Category 2)¶
| Method | Path | Description |
|---|---|---|
GET |
/api/v1/mes/sites/{site}/production-capability |
Site-level equipment status and availability summary |
GET |
/api/v1/mes/sites/{site}/equipment/{name}/status |
Single unit status and availability |
Work Orders (ISA-95 Category 3)¶
| Method | Path | Description |
|---|---|---|
POST |
/api/v1/mes/sites/{site}/work-orders |
Submit a work order (?dispatch=immediate is required and creates the Batch now; it still awaits an operator Start). Resubmitting a work order ID returns the existing order with 200, or 409 when the body differs |
GET |
/api/v1/mes/sites/{site}/work-orders |
List work orders, one per batch in the site |
GET |
/api/v1/mes/sites/{site}/work-orders/{id} |
Get work order status by ID |
DELETE |
/api/v1/mes/sites/{site}/work-orders/{id} |
Cancel a work order that has not started; 409 once its batch has begun executing |
Production Performance (ISA-95 Category 4)¶
| Method | Path | Description |
|---|---|---|
GET |
/api/v1/mes/sites/{site}/production-performance |
List completed batch summaries |
GET |
/api/v1/mes/sites/{site}/production-performance/{batchID} |
Full consolidated production record |
External Results (data in from a laboratory or other system)¶
A phase whose chart calls AWAIT_RESULT('assay-titre', 0, 100, 'g/L') parks until a system outside the control system delivers that measurement. This is the seam a LIMS integration uses. See ADR 0055 for why it is separate from the operator prompt path.
These endpoints record provenance. No electronic signature is collected. They are the one inbound-to-a-running-phase path an API key can call, precisely because nothing here is signed. The delivering system, the credential and the sample id are recorded in a signature's place. The prompt-response endpoint remains closed to machine identities and always will be.
The delivery endpoint is closed the other way, to people. An unsigned entry is defensible only while the identity making it cannot sign, so POST …/external-result refuses any caller holding a human role with 403 Forbidden. A person answering this wait uses the manual fallback below, which signs the entry. The GET …/pending list carries no such restriction. Reading what a phase is waiting for is an ordinary read.
| Method | Path | Description |
|---|---|---|
GET |
/api/v1/mes/sites/{site}/external-results/pending |
List phases currently awaiting an external result, with the wait id, resultKey, declared min/max/unit, and the owning batchID |
POST |
/api/v1/mes/sites/{site}/phases/{name}/external-result |
Deliver a measurement and resume the phase |
Request body:
{
"resultID": "a1b2c3d4e5f6a7b8",
"resultKey": "assay-titre",
"value": "42.5",
"externalSystem": "LabWare LIMS",
"sampleID": "S-2026-0912"
}
externalSystem is required. A value whose origin the batch record cannot name has no provenance, and provenance is the only thing standing in for a signature here. resultKey must match the pending wait. The resultID alone would route the value, but a laboratory answering the wrong assay is a real failure mode, and the chart already declared which measurement this step wants.
Validation. value must parse as a number within the [min, max] the chart author declared, through the same check an operator's PROMPT_VALUE answer goes through. Out-of-range or non-numeric values return 400 Bad Request and nothing is delivered. A resultID that names no pending wait, or a phase whose run has ended, returns 409 Conflict.
Manual fallback. When the external system cannot deliver, an operator answers the same wait through POST /api/v1/sites/{site}/phases/{name}/prompt-response, passing the wait's id as promptID. That path is unchanged: fresh token, electronic signature, required reason. It is recorded as an operator action with the signature meaning external-result-manual-entry. It is never recorded as a delivered measurement.
In the batch record. A delivered measurement appears in batchRecord.spec.externalResults[] with its delivering system, sample id and credential. It never appears in operatorActions[], which is reserved for things a person signed for. A manual entry appears in operatorActions[], because that is what it is.
OPC UA Discovery¶
Transient lookups consumed by the Device Discovery wizard (#347) and the standalone OPC UA Explorer view. These endpoints do not persist anything. They open an OPC UA session, browse/read/write, and disconnect. The wizard emits ControlModule or Unit (with serviceBinding) CRs from the results, applied through /api/v1/apply.
| Method | Path | Description | Permission |
|---|---|---|---|
POST |
/api/v1/discovery/opcua/endpoints |
List advertised endpoints + cert fingerprints | Engineer |
POST |
/api/v1/discovery/opcua/browse |
Return one level of the address-space tree, plus the server's NamespaceArray | Engineer |
POST |
/api/v1/discovery/opcua/search |
Bounded recursive substring search of the subtree below a given root | Engineer |
POST |
/api/v1/discovery/opcua/read |
Ad-hoc read of a single OPC UA node | Engineer |
POST |
/api/v1/discovery/opcua/write |
Ad-hoc write to a single OPC UA node | Engineer |
POST |
/api/v1/discovery/opcua/method-args |
Introspect an OPC UA Method node; returns its InputArguments / OutputArguments. Used to pre-populate UnitService.inputs / outputs. |
Engineer |
POST |
/api/v1/discovery/opcua/variables |
List the Variable children of a node with their DataType / AccessLevel attributes. Used to pre-populate a discovered ControlModule's tags. |
Engineer |
POST |
/api/v1/discovery/opcua/services |
Examine a node for the MTP ServiceControl variable shape (ADR 0045); returns recognized module services with resolved control-attribute addresses. Used to pre-populate a discovered Unit's MTP serviceBinding.services. |
Engineer |
GET |
/api/v1/discovery/opcua/client-identity |
Return the gateway's auto-provisioned discovery client certificate (public part) so an operator can trust-list the DCS on a secured OPC UA server. Never exposes the private key. | Engineer |
The legacy Asset CRD, AssetMethodCall PhaseTemplate body, /api/v1/sites/{site}/assets/* endpoints, and Trust List inbox were removed in #348. Smart devices are now Units with spec.serviceBinding. Sensors are ControlModules. See ADR-0001 and the migration guide.
Security tuple¶
securityPolicy is one of: None, Basic128Rsa15, Basic256, Basic256Sha256, Aes128_Sha256_RsaOaep, Aes256_Sha256_RsaPss. securityMode is one of: None, Sign, SignAndEncrypt. authMode is one of: anonymous, username, certificate.
For pharma topologies, securityPolicy: None + securityMode: None is rejected with 400 unless the gateway is started with gateway.opcua.allowInsecure: true (Helm value).
Client certificates. Secured channels (securityMode: Sign/SignAndEncrypt) and X.509 user-token auth (authMode: certificate) are supported on the discovery endpoints as well as on Unit serviceBinding connections (#511). Cert material is PEM-encoded (PKCS#1 and PKCS#8 private keys accepted, encrypted PEM not supported) and is carried under the same key names on both paths: the clientCert/clientKey keys of the credentialsRef Secret, or the clientCert/clientKey fields of the inline credentials object. A discovery request whose tuple requires a client cert but supplies none is rejected with 400 ("client certificate + key are required for this security tuple").
Credentials¶
Each handler accepts either credentialsRef (a Secret in the gateway's pod namespace) or an inline credentials object, and never both (400 when both are set).
A credentialsRef Secret may carry the keys authMode, username, password, clientCert, clientKey. When authMode is set on the security tuple it overrides the Secret's value. The inline credentials object accepts the same names as JSON fields (authMode, username, password, clientCert, clientKey).
POST /endpoints¶
Request:
{ "endpoint": "opc.tcp://server:4840" }
Response:
{
"endpoint": "opc.tcp://server:4840",
"endpoints": [
{
"endpointURL": "opc.tcp://server:4840",
"securityPolicy": "Basic256Sha256",
"securityMode": "SignAndEncrypt",
"securityLevel": 4,
"serverCertSha256": "ab12...",
"authModes": ["anonymous", "username"],
"serverName": "Example UA Server",
"serverURI": "urn:example:server"
}
]
}
POST /browse¶
Request:
{
"endpoint": "opc.tcp://server:4840",
"security": { "securityPolicy": "Basic256Sha256", "securityMode": "SignAndEncrypt", "authMode": "anonymous", "serverCertSha256Pin": "ab12..." },
"fromNode": "i=85"
}
Response:
{
"fromNode": "i=85",
"serverCertSha256": "ab12...",
"children": [
{
"browseName": "2:Pump1",
"displayName": "Pump 1",
"nodeID": "ns=2;s=Pump1",
"nodeClass": "Object",
"typeDefinition": "ns=4;i=1",
"typeDefinitionNamespaceURI": "http://opcfoundation.org/UA/PADIM/",
"classSignal": "data",
"hasChildren": true
},
{
"browseName": "2:Pump1.Speed",
"nodeID": "ns=2;s=Pump1.Speed",
"nodeClass": "Variable",
"typeDefinition": "i=63",
"value": 1234.5,
"quality": "Good",
"engineeringUnit": "rpm",
"euRange": { "low": 0, "high": 3000 }
}
]
}
Each child (and each /search hit) additionally carries
typeDefinitionNamespaceURI and classSignal when the node's
type-definition resolves into a recognized device-model companion namespace:
PA-DIM (http://opcfoundation.org/UA/PADIM/) or OPC UA for Devices
(http://opcfoundation.org/UA/DI/). classSignal: "data" marks it a
data-plane field device. Both fields are omitted otherwise, and the wizard
falls back to the structural Variables-vs-Methods signal. See
ADR-0022.
POST /read¶
Request:
{
"endpoint": "opc.tcp://server:4840",
"security": { "securityPolicy": "Basic256Sha256", "securityMode": "SignAndEncrypt", "authMode": "anonymous" },
"nodeID": "ns=2;s=Pump1.Speed"
}
Response:
{
"nodeID": "ns=2;s=Pump1.Speed",
"value": 1234.5,
"quality": "Good",
"qualitySubCode": "",
"timestamp": "2026-05-08T12:34:56Z",
"serverCertSha256": "ab12..."
}
POST /write¶
Request:
{
"endpoint": "opc.tcp://server:4840",
"security": { "securityPolicy": "Basic256Sha256", "securityMode": "SignAndEncrypt", "authMode": "username" },
"credentialsRef": { "name": "opcua-creds" },
"nodeID": "ns=2;s=Pump1.Setpoint",
"value": 1500,
"dataType": "Double"
}
dataType is one of Bool, Int, UInt, Double, String. When omitted, the JSON-decoded value is forwarded as-is and gopcua's reflection picks a type. Explicit dataType is recommended for Bool and integer writes because JSON booleans/numbers do not round-trip to the OPC UA scalar types unaided.
Response:
{ "nodeID": "ns=2;s=Pump1.Setpoint", "ok": true, "serverCertSha256": "ab12..." }
Errors:
400— invalid request (e.g. None/None on a pharma gateway, bothcredentialsandcredentialsRefset, missing required field, missingclientCert/clientKeyfor a tuple that requires a client cert, or the server does not advertise the requested security policy + mode)403— server-cert SHA-256 fingerprint did not matchserverCertSha256Pin404—credentialsRefSecret not found in the gateway's namespace502— upstream OPC UA error (connect failure, GetEndpoints rejected, etc.)
(501 is reserved for a security tuple the schema admits but the connector cannot drive. Since #317 implemented Sign/SignAndEncrypt and certificate auth at the connector level, no currently accepted tuple returns it.)
MTP Manifest Import¶
The same CR-emitting step as the discovery wizard, with a vendor's file in place of a live scan (#1497). A VDI/VDE/NAMUR 2658 Module Type Package already describes the skid's services, their procedures and every OPC UA address. A discovery scan is therefore only one way to learn them. The file is also the only way to learn the ones a running server cannot answer for, such as a procedure's ID and whether it self-completes.
| Method | Path | Description | Permission |
|---|---|---|---|
POST |
/api/v1/import/mtp |
Read an uploaded MTP manifest and return the generated IOModule, Unit and ControlModule CRs plus a per-item import report |
Engineer |
The request is multipart/form-data. file is the manifest: a .mtp/.amlx container or a bare .aml, detected by content, with the extension ignored. The remaining fields place and secure what comes out: site (omit it to read the file without emitting anything), processCell, endpoint, name, securityPolicy, securityMode, authMode, credentialsSecret, serverCertPin.
The endpoint applies nothing. It returns each resource as both an object and its YAML. The caller posts them through /api/v1/apply, which carries its own per-kind permission gate. Uploads are capped at 8 MB, above the gateway's global 1 MB body limit, because MTP manifests are XML and routinely exceed it.
findings[] is the report, and it is the part to read. Each entry names one item the file declares, with a severity of refused (not emitted at all), incomplete (emitted with a field the file could not fill) or note. A response with clean: false is not a failed import. It is an import that says what it could not carry. Two findings are common enough to expect. A manifest in the older ServiceStrategy vocabulary has every service refused, because its command word coding differs from the VDI 2658-4 Table 14 coding the runtime writes and the file does not say what it is. An analogue module whose VSclMin/VSclMax arrive as OPC UA node addresses, with no written numbers, is emitted without engMin/engMax, because the range lives on the PEA.
dcs mtp import is the same code path from a terminal, with the CRs on stdout and the report on stderr.
Audit Records¶
| Method | Path | Description |
|---|---|---|
GET |
/api/v1/audit |
List all audit records (cross-site, paginated) |
GET |
/api/v1/sites/{site}/audit |
List audit records for a site |
GET |
/api/v1/sites/{site}/audit/{name} |
Get a single audit record |
GET |
/api/v1/sites/{site}/audit/correlation/{correlationID} |
List audit records by correlation ID |
GET |
/api/v1/audit/verify |
Verify electronic signatures across all signed records |
POST |
/api/v1/signatures |
Mint an ElectronicSignature bound to an external ref (change ticket, commit SHA) for annotation-based change control |
GET |
/api/v1/audit/verify-archived |
Verify archive manifest signatures for archived audit records (via historian) |
GET |
/api/v1/audit/archive-integrity/latest |
Latest scheduled archive-integrity check result |
GET |
/api/v1/audit/archive-integrity/history |
Past archive-integrity check outcomes |
POST |
/api/v1/audit/archive-integrity/run |
Run an archive-integrity check now and return its result |
Archive reachability. An audit list is merged from the AuditRecords still
in etcd and the records the archiver has retired into the historian, and the
three list routes above say which halves they could read. Every response
carries an archive object beside items:
{
"items": [],
"metadata": {},
"archive": {
"status": "unreachable",
"reason": "the archive did not answer: connection refused"
}
}
status is one of ok, unreachable, and not-configured. The last of those
means the deployment archives nothing, so etcd is the whole trail. reason is
present only on unreachable.
Read the field. While the historian is down, a range whose records have all been archived comes back empty and 200. Those bytes are identical to a range in which nothing happened. For a 21 CFR Part 11 trail the reader is an auditor with no other way to tell the two apart.
The list routes answer 200 and a flag. A 503 was the alternative, and it would take the etcd half away for the duration of a historian outage. That trade only holds if the caller reads the flag.
The single-record route GET /api/v1/sites/{site}/audit/{name} has no partial
answer to keep readable, so it refuses. A record absent from etcd whose archive
lookup failed answers 503. It does not answer 404, because a 404 there
would be a statement that the record does not exist.
dcs audit export reads the same field. An export the gateway called short is
written and then reported, and the command exits non-zero unless
--allow-partial is passed.
Action values. spec.action enumerates the operation type. Current
values: Create, Update, Delete, Command, Approve, Reject,
Promote, StateTransition, Allocate, Deallocate, Synchronize,
Finalize, PromptAcknowledge, InvalidateSetupToken, Reconcile,
DriftCorrected.
InvalidateSetupToken is historical. The setup-token bootstrap that emitted
it was retired with ADR 0004 (#487). The value remains in the schema so
records written before the retirement stay valid (see
Security Operations).
DriftCorrected records an out-of-band mutation of a controller-managed
resource (e.g. a kubectl patch against a compiled ControlProgram, or an
edit to a unit's runtime I/O or tag-map ConfigMap) that the
owning reconciler detected and reverted to the declared state. The record's
changes[] carries the reverted fields, and the message names the foreign
field manager(s) recovered from metadata.managedFields, best-effort.
Signature verification (GET /api/v1/audit/verify):
Validates that every signed AuditRecord references a key ID present in the
versioned signing key store. Use after a key rotation to confirm historical
signatures remain verifiable. Requires Supervise permission.
Query parameters: ?site=<name> to limit to a specific site.
{
"total": 42,
"passed": 40,
"failed": 0,
"legacy": 2,
"results": [
{"name": "ar-abc123", "namespace": "site-main", "action": "Approve", "signerID": "jane@example.com", "keyID": "v2", "valid": true},
{"name": "ar-old456", "namespace": "site-main", "action": "Finalize", "signerID": "bob@example.com", "keyID": "", "valid": true, "error": "legacy signature (no keyID) — manual verification required"}
]
}
Signature minting (POST /api/v1/signatures):
Mints an ElectronicSignature HMAC-bound to a caller-supplied external
reference: the change ticket or merged commit SHA an out-of-band change
carries in its external-ref annotation. This is the signer the GitOps CI
step and ticket bridges call in Change Control.
The change-control admission webhook verifies the returned signature
against the same external ref at apply time. Requires Supervise
permission (§11.10(g)). Refused attempts are audited.
Body: {"meaning": "approved", "externalRef": "CHG0001234", "site": "main"}
(meaning is lowercase kebab-case and becomes the annotation-key suffix,
and site scopes the mint's AuditRecord).
{
"annotationKey": "dcs.io/esig-approved",
"signature": {
"signerID": "jane@example.com",
"signerName": "Jane Doe",
"signedAt": "2026-07-05T21:00:00Z",
"meaning": "approved",
"digest": "a1b2c3d4...",
"keyID": "v2"
}
}
Embed the signature object JSON-encoded as the annotationKey
annotation on the applied object, alongside the external-ref annotation
your ChangeControlPolicy allowlist entry names.
Authentication¶
| Method | Path | Description |
|---|---|---|
GET |
/api/v1/auth/config |
Auth configuration the SPA/CLI needs before login (authMode, operatingMode, backend, reasonMinLength, session-lifecycle policy uiIdleLockSeconds/sessionInactivitySeconds, systemUseNotification when configured; OIDC issuerURL/clientID/scopes when applicable) |
POST |
/api/v1/auth/login |
LDAP login — returns a gateway-issued JWT (404 unless auth mode is ldap; 401 on a credential rejection, 503 when the directory itself is unreachable — only the former counts toward SR 1.11 lockout) |
GET |
/api/v1/auth/session |
The authenticated caller's identity and resolved permission set (ADR 0005) |
GET |
/api/v1/auth/entitlements |
The authenticated caller's resolved effective action set (ADR 0024) |
GET |
/api/v1/auth/policy |
The active role→action policy resolution — per role, configured tiers/allow/deny plus the effective action set (ADR 0024; supervise) |
POST |
/api/v1/auth/logout |
Invalidate the caller's own session ID server-side (IEC 62443 CR 3.8, #971); any authenticated identity. Also closes the WebSocket, SSE and terminal streams that session is holding (#1445) |
GET |
/api/v1/auth/sessions |
List sessions tracked by this replica (admin) |
DELETE |
/api/v1/auth/sessions/{id} |
Terminate a session by local authority (IEC 62443 SR 2.6, #971; admin, audited). The session's live streams are closed with it at once (#1445) |
GET |
/api/v1/auth/lockouts |
List login-lockout state (IEC 62443 SR 1.11, #974; admin) |
DELETE |
/api/v1/auth/lockouts/{username} |
Clear a login lockout by administrator action (audited) |
config and login are unauthenticated by necessity. The login body is {"username": "...", "password": "..."}. The response carries token, expiresIn (seconds), and a user object. Failed and successful logins are both audited (21 CFR Part 11 §11.10(e)).
session requires a valid token but deliberately carries no permission gate. Even a user whose groups match no role can learn their (empty) permission set, which the UI uses to route them. The user object in the login response and the session response share one shape: subject, name, email, groups, permissions, and roles. permissions is the user's groups resolved against the gateway's active role table (shipped defaults or the deployment's roles file). roles is the display view of the same resolution: the caller's groups that name a configured role, most-privileged first:
{
"subject": "jane@example.com",
"name": "Jane Doe",
"email": "jane@example.com",
"groups": ["dcs-engineer", "dcs-site-plant-a"],
"permissions": ["read", "operate", "operate-lead", "engineer"],
"roles": ["dcs-engineer"]
}
roles omits groups that name no role (the dcs-site-* site assignments and any group the deployment does not map), and omits dcs-mes-integration, an integration identity shape with no human behind it. It exists so a client can SHOW who is acting and under what role without guessing which of a token's groups is the role. The gateway UI renders it beside the user's name in the header.
The UI gates pages and controls on permissions. It never gates on roles or raw group names, because role definitions are deployment configuration (see Custom roles).
entitlements resolves the caller's effective action set under the active policy (ADR 0024): the union, across the caller's roles, of each role's (tier actions ∪ allow) − deny. The response is {"actions": [...]} with names from the action catalog, sorted. With no policies configured it is exactly the actions the caller's permission tiers pin. Like session, it requires a valid token and no permission. The UI fetches it once per session and gates per-affordance controls on it.
Search¶
| Method | Path | Description |
|---|---|---|
GET |
/api/v1/search |
Cross-resource name search (?q= required, 2–256 characters; ?limit= per-type result cap, default 5, max 10) |
Export and Backup¶
| Method | Path | Description |
|---|---|---|
GET |
/api/v1/export |
Clean manifest for one resource (?kind= and ?name= required; ?site= required for namespaced kinds; YAML by default, ?format=json for JSON) |
GET |
/api/v1/backup/crds |
Bulk export of DCS resources (admin-only) |
GET |
/api/v1/backup/audit |
Bulk export of audit records (admin-only) |
Validation¶
Server-side validation used by the editors. Nothing is persisted.
| Method | Path | Description |
|---|---|---|
POST |
/api/v1/validate/st |
Validate IEC 61131-3 Structured Text ({"source": "...", "mode": "program"\|"expression"}) |
POST |
/api/v1/validate/sfc-chart |
Validate an SFC chart structure ({"chart": {...}, "params": [...]}) |
Both return {"valid": true} or {"valid": false, "errors": [{"line", "column", "message"}]}.
Anti-Replay Protection (IEC 62443 SR 3.8)¶
All mutating requests (POST, PUT, DELETE) must include the
X-DCS-Nonce header with a unique value (recommended: UUID v4). The gateway
rejects requests with a previously seen nonce within the configured window
(default 10 minutes).
| Header | Required on | Value |
|---|---|---|
X-DCS-Nonce |
POST, PUT, DELETE |
Unique string per request (e.g. UUID v4) |
Error responses:
- 400 Bad Request — X-DCS-Nonce header missing on a mutating request
- 409 Conflict — nonce was already used within the replay window
The dcs CLI adds this header automatically. Browser clients generate it via
crypto.randomUUID().
Error Format¶
All errors return a JSON object:
{"error": "descriptive error message", "status": 400}
Validation errors include a details field with per-field messages:
{
"error": "validation failed",
"status": 400,
"details": "name: \"FERM-2026-0411\" is not a valid Kubernetes name (lowercase alphanumeric and hyphens, max 63 chars); protocol: required"
}
A name refused for its format is echoed back in the detail, truncated at 96 characters. Every field held to the Kubernetes name format is a resource name or a reference to one, and a refusal that describes only the rule leaves a caller holding several identifiers unsure which one the server means. A batch, for instance, answers to a resource name and a batch ID, and only the first is a legal Kubernetes name. No other validation rule echoes its value.
All state-changing endpoints validate request bodies using struct tags (required fields, Kubernetes name format, ISA-88 command/mode enums, max lengths). Unknown JSON fields are rejected.
Common status codes:
400Bad Request — invalid JSON, validation failure, unknown fields, or missingX-DCS-Nonceheader404Not Found — resource does not exist409Conflict — resource already exists or duplicate request nonce (anti-replay)429Too Many Requests — rate limit exceeded500Internal Server Error — Kubernetes API error
Deleting something that is not there¶
Every DELETE returns 404 Not Found when the target does not exist, on every
kind. This is stated once here, for every route at once.
A 500 would be the server reporting that the server broke, and nothing broke:
the caller asked for a state the cluster is already in. Idempotent teardown can
therefore key on the status code. Sniffing the message text is never necessary,
and treating a genuine server fault as success to work around it hides real
failures.
Mutating something that is running¶
Every DELETE, PUT and POST route carries a written answer to one question:
can this call arrive while its target is executing, and is doing it then unsafe?
The answers live in scripts/.mutation-preconditions.tsv, one row per route,
and ADR 0060
explains why they are written down. Leaving the answer to each handler is what
let a running batch stay deletable for a year. A route whose answer is yes
refuses with 409 Conflict and records the attempt as a Rejected audit record.
The trail therefore shows the change was asked for and refused.
For DELETE the rule is whole-object. A Batch is deletable in a terminal phase
or before it starts, and a procedural element is deletable in the four states
ISA-88 Clause 7.5.2 gives the procedural model as Initial and Final. Anything
else is refused. The way past a refusal is Stop or Abort, which sequence the
equipment somewhere safe on the way.
For PUT the rule is per field, because some edits are legitimate mid-run and
some are not. Renaming a unit's HMI symbol during a batch changes nothing the
batch resolves. Rewriting the same unit's cmRoles rebinds the equipment its
running phases resolve through. Each guarded route therefore names the spec
fields that may still move while the target executes, and refuses the rest. The
refusal says which of the submitted fields it objected to, so a caller that
only meant to fix a description can see which other field its client sent.
For POST most routes are a create or a command, and neither has a target whose
state could make it unsafe. A create has nothing to act on yet. A command goes
through the ISA-88 state machine, which carries its own preconditions. The
exceptions are the POST routes that act on a record that already exists. Those
carry the same rule the PUT routes carry.
Generic Apply is a create or an update depending on what is
already there, so it refuses per kind on the same grounds the typed route does.
What it compares is the spec its handler assembled. The request body says
nothing reliable, because every handler merges its DTO through conditionals.
Reverting a recipe revision replaces a controlled
document's whole spec, so it refuses the phases in which that document is
released or in use.
The gateway is where this is enforced, because the gateway is the operator API.
The admission webhooks the chart installs read a document's own well-formedness
and the 21 CFR Part 11 immutability rules. None of them reads what the resource
is currently doing, so no execution-state refusal happens at the apiserver.
Direct kubectl access sits outside operator RBAC by design.
One route answers differently, and deliberately. PUT
/api/v1/sites/{site}/fbnetworks/{name} accepts the spec edit and the control
operator then defers the deployment while a batch holds the unit, raising a
HotSwapDeferred condition. The CR write is harmless on its own and the swap is
the hazard, so deferring it leaves room for the authorized bypass a 409 would
take away. That bypass takes a 21 CFR Part 11 reason. See
Control Programs.
DELETE /api/v1/sites/{site}/fbnetworks/{name} does not follow the PUT on the
same object. It refuses with 409 while the unit named in spec.unitRef is
executing. A deferral suits an edit because the unit keeps closed-loop control
across the swap, and there is nothing equivalent to defer here. The finalizer
removes the program from the live runtime. That drops its outputs to their
safeValue with no sequencing. The Unit itself still reads normally afterwards,
so the batch keeps advancing phases into equipment that is no longer controlled.
Stop or abort the batch to remove the program. To change the logic instead, use
the PUT and its deferral (#1586).
PUT /api/v1/fbblocktypes/{name} and DELETE /api/v1/fbblocktypes/{name} are
deferred by the same gate, for the same reason. A block type is cluster-scoped
and the programs that use it are reachable only by walking every
ControlProgram's block list through the composite graph, which is work the
control operator already does on every reconcile. Editing or deleting a type
therefore changes the content of every program that resolves it without
touching any of them, and each affected program defers its own swap while a
batch holds its unit (#1574).
Unknown-site handling¶
Every site-scoped route (/api/v1/sites/{site}/… and the MES variant
/api/v1/mes/sites/{site}/…) returns 404 Not Found when {site} names a
Site that does not exist. This is enforced once, ahead of the handlers. The
behavior is therefore uniform across all list and item routes. A request for a real site
that simply has no matching resources still returns 200 OK with an empty
collection: 404 means "no such site", 200 {"items": []} means "site exists,
nothing to list". (Previously a bogus site resolved to an empty namespace and
every list route answered 200-empty, which hid client bugs such as a
malformed site name. See #937.)