Skip to content

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, Removing
  • health — current operational health: healthy, degraded, offline, unknown
  • healthReason — one-line explanation behind health
  • address — device IP discovered from the node's InternalIP
  • nodeRef — Kubernetes node name
  • displayName — the plant's name for the chassis, resolved at read time from the bound node's dcs.io/display-name annotation, and empty when the node carries none
  • ioProbePodName — name of the io-probe pod monitoring IOModule connectivity
  • ioProbeReady — whether the io-probe pod is running and ready
  • lastAlive — 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's holdingChart is 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 is leaseDurationSeconds instead, 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 information
  • regulatoryNotes — compliance requirements and documentation references
  • operatorInstructions — instructions for manual steps
  • documentReferences[] — references to external documents, each with name, type (SDS, SOP, Specification, Diagram), optional url and id

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) and spec.parameterValues (resolved runtime values from the recipe formula).
  • Operation: spec.parameters and spec.parameterValues, resolved from the parent UnitProcedure's scoped values merged with the OperationTemplate's formal interface.
  • Phase: spec.parameters and spec.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-valve MISMATCH)

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.signatures must be empty in the manifest (400 otherwise) — 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 401 and reauthentication_required otherwise).
  • spec.target (apiVersion, kind, name) and spec.reason are required. spec.operation defaults to Update and must be one of Create, Update, Delete.
  • On success apply returns 200 OK (consistent with every other apply kind), whereas the REST create endpoint returns 201 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, both credentials and credentialsRef set, missing required field, missing clientCert/clientKey for 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 match serverCertSha256Pin
  • 404credentialsRef Secret not found in the gateway's namespace
  • 502 — 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.

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 RequestX-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:

  • 400 Bad Request — invalid JSON, validation failure, unknown fields, or missing X-DCS-Nonce header
  • 404 Not Found — resource does not exist
  • 409 Conflict — resource already exists or duplicate request nonce (anti-replay)
  • 429 Too Many Requests — rate limit exceeded
  • 500 Internal 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.)