Runtime Engine¶
Runtime-engine lookup for clocks, tasks, retain, launching, process image handling, online change plumbing, and deployment mechanics.
Related: Runtime Semantics, Bytecode Format
Status: Implemented architecture. Every runtime runs the program compiled into a program image for its CPU (specification 44); the bytecode engine, STBC and its VM, was deleted on 24 October 2026 (specification 44 §15). Helper evaluation remains for bounded const/debug/config flows. Where this document describes the bytecode engine's internals, it records that deleted engine; where it states how a program behaves, the statement holds for compiled programs, and its references to bytecode read as references to the program image (specification 44).
1. Purpose¶
This document specifies the architecture for a portable Structured Text (ST) runtime capable of executing IEC 61131-3 compliant programs. The initial implementation targets desktop operating systems (Linux, Windows, macOS); embedded support is planned.
2. Design Goals¶
| Goal | Description |
|---|---|
| Portability | Single runtime codebase runs on desktop and embedded targets |
| Determinism | Predictable scan cycle execution suitable for automation |
| IEC Compliance | Align task scheduling and execution semantics with IEC 61131-3 Ed.3 |
| Simplicity | Minimal clock abstraction surface |
| Testability | Full runtime testable on desktop without hardware |
3. Architecture Overview¶
┌──────────────────────────────────────────────────┐
│ ST Program (Program Image) │
└──────────────────────┬───────────────────────────┘
▼
┌──────────────────────────────────────────────────┐
│ ST Runtime Core │
│ ┌────────────┐ ┌──────────────────┐ ┌──────────────┐ │
│ │ Executor │ │Resource Scheduler│ │ Timer System │ │
│ └────────────┘ └──────────────────┘ └──────────────┘ │
│ ┌────────────────────────────────────────────┐ │
│ │ Process Image │ │
│ └────────────────────────────────────────────┘ │
└──────────────────────┬───────────────────────────┘
▼
┌──────────────────────────────────────────────────┐
│ Clock Trait │
└───────────┬──────────────────────┬───────────────┘
▼ ▼
┌───────────────────┐ ┌───────────────────┐
│ StdClock │ │ ManualClock │
│ (Linux/Win/Mac) │ │ (Tests/Sim) │
└───────────────────┘ └───────────────────┘
4. Clock Abstraction Layer¶
4.1 Rationale¶
The runtime requires only a monotonic clock and a way to sleep until a deadline. Rather than abstracting entire operating systems, we abstract only what the scheduler actually uses. This keeps the abstraction minimal and each clock implementation small.
4.2 Clock Trait Definition¶
pub trait Clock: Send + Sync + 'static {
/// Returns monotonic time for scheduling (nanosecond Duration).
fn now(&self) -> Duration;
/// Sleeps until a target time. Used only by real resource threads.
fn sleep_until(&self, deadline: Duration);
/// Wake any sleepers (best-effort).
fn wake(&self) { /* optional */ }
}
The runtime scheduler uses a Clock for time and pacing. Thread creation and mutexing are handled by Rust’s standard library, keeping the abstraction surface minimal.
4.3 Why Only a Clock¶
| Operation | Justification |
|---|---|
now |
Required for scan cycle timing and IEC timers (TON, TOF, TP) |
sleep_until |
Paces resource cycles in real threads |
wake |
Allows clean shutdown of resource threads |
The clock interface excludes file I/O (bytecode loaded at init), networking (handled separately via I/O abstraction), and explicit mutex APIs (runtime uses RwLock/Mutex internally). Control-path allocation requirements are profile-specific, as described in §6.1 and specification 34; this interface does not establish allocation-free hosted execution.
5. Clock Implementations¶
5.1 StdClock (Desktop)¶
Targets: Linux, Windows, macOS
Implementation: Uses Rust standard library (Instant, thread::sleep).
pub struct StdClock {
start: Instant,
}
impl Clock for StdClock {
fn now(&self) -> Duration {
let elapsed = self.start.elapsed();
let nanos = i64::try_from(elapsed.as_nanos()).unwrap_or(i64::MAX);
Duration::from_nanos(nanos)
}
fn sleep_until(&self, deadline: Duration) {
let now = self.now();
let delta = deadline.as_nanos() - now.as_nanos();
if delta <= 0 {
return;
}
let delta = u64::try_from(delta).unwrap_or(u64::MAX);
thread::sleep(std::time::Duration::from_nanos(delta));
}
}
Justification: Rust’s standard library already abstracts Linux/Windows/macOS differences. Task priority is enforced by the runtime scheduler; OS thread priority is best-effort only and may be ignored.
5.2 ManualClock (Tests)¶
Deterministic clock for unit tests and simulation. Time advances explicitly; no real sleeping occurs. Used by scheduler tests and trace reproducibility checks.
Advancement uses saturating signed-nanosecond arithmetic: a delta beyond the
representable Duration range clamps to the corresponding bound and never
wraps or panics. Explicit set_current_time remains the separate mechanism for
placing a test clock at an earlier instant.
5.2.1 Runtime logical time and non-pacing tick¶
StdClock::now exposes nondecreasing elapsed monotonic time. No strict progress
or minimum resolution is guaranteed between adjacent samples.
ResourceRunner::tick is one non-pacing scheduler step. It samples the
injected clock and executes one resource cycle without calling
Clock::sleep_until; pacing and deadline sleeps belong to the spawned resource
loop. A ManualClock therefore remains fixed until explicitly advanced.
The zero-argument truST compatibility call TIME() returns the runtime's
current injected logical elapsed time as a TIME value. It does not read civil
wall time. Harness and simulation advances affect the result only through the
runtime or manual clock.
Periodic task readiness likewise uses only the injected clock. A direct tick with a fixed manual clock does not make the task ready; advancing that clock to the task deadline makes one activation eligible under the readiness contract below.
CURRENT_DT() is separate. It samples the host SystemTime once per call and
returns the UTC Unix timestamp as timezone-naive DT ticks with epoch
1970-01-01T00:00:00Z and fixed one-millisecond resolution. Positive
sub-millisecond fractions are truncated. Tick 0 through i64::MAX are
accepted; a pre-epoch or larger host value returns RuntimeError::Overflow.
Local timezone and DST are not applied.
The injected runtime/manual clock, scheduler scaling, simulation time, and
replay time do not control CURRENT_DT, and its samples are not made
monotonic. A host-clock rollback may therefore produce a lower later value.
Deterministic replay excludes programs that read CURRENT_DT unless the
environment controls the host clock. The complete callable contract is in
docs/specs/07-standard-functions.md#host-civil-clock-current_dt.
5.3 Embedded Runtime and Clock (Planned)¶
Embedded execution requires the shared-engine migration defined in
Runtime Portability, not only another Clock
implementation. The current hosted dispatcher depends on Runtime, hosted
storage/services, initialization metadata, and OS timing. The migration extracts
compiler-free construction, initialization, execution, storage, and supervision
boundaries while retaining common IEC semantics and supported hosted behavior.
Board adapters supply time, process-image I/O, persistence, watchdog, and startup mechanisms. A bare-metal or RTOS-backed clock is a composition choice; it does not change common PLC scheduling. The STM32 target is the STM32U5A9 (the first reference board, NUCLEO-F401RE, was retired 17 October 2026 by the owner, and the STM32F407 was deleted with the bytecode engine on 24 October 2026); the provisional second target is ESP32-C6. Neither is qualified by this specification update. Current hosted behavior remains authoritative until the corresponding planned profile is implemented and validated.
6. Runtime Components¶
6.1 Executor¶
Runs the program compiled into a program image (specification 44). On a
computer, trust-runtime runs the image's module with the chips' compiled cycle
(CompiledCycle of trust-runtime-services) over the computer's ports, the
process image, the drivers, the clock, the retain file and the debugger's hold
(specification 44 §8.8, NX-14). The kernel gives the program its memory areas,
its statement budget and its faults (specification 44 §7, §8).
The bytecode executor that this section described, with its stack path, its register-IR and tier-1 paths and the planned H optimized-tier migration (specification 34 HOST-04/MODEL-02), was deleted with the bytecode engine (specification 44 §15).
6.2 Task Manager (Resource Scheduler)¶
Implements IEC 61131-3 task scheduling and program organization unit (POU) associations (IEC 61131-3 Ed.3, §6.8.2; Tables 62–63).
Each IEC resource runs inside a dedicated scheduler loop. The scheduler is executed on an OS thread started via std::thread::spawn; IEC tasks are not OS threads.
IEC task model: - Tasks are periodic (INTERVAL) or event-driven (SINGLE rising edge). (IEC 61131-3 Ed.3, §6.8.2 a–b) - If INTERVAL is non-zero, periodic scheduling occurs only while SINGLE is 0. (IEC 61131-3 Ed.3, §6.8.2 b) - If INTERVAL is zero, no periodic scheduling occurs. (IEC 61131-3 Ed.3, §6.8.2 b) - PRIORITY establishes scheduling order with 0 as highest priority and larger numbers as lower priority. (IEC 61131-3 Ed.3, §6.8.2 c; Table 63) - A program with no task association executes once per resource cycle at the lowest priority. (IEC 61131-3 Ed.3, §6.8.2 d) - A function block instance associated with a task executes only under that task, independent of program evaluation rules. (IEC 61131-3 Ed.3, §6.8.2 e)
Scheduling policy (implementer choice permitted by IEC 61131-3, §6.8.2 c): - Deterministic, non-preemptive, fixed-priority scheduling per resource. - Ready tasks at the same priority run in FIFO order by longest waiting time. - Event tasks are edge-detected on the SINGLE input and enqueue one activation per rising edge.
pub struct TaskConfig {
pub name: String,
pub interval: Duration, // INTERVAL; zero disables periodic scheduling
pub single: Option<String>, // SINGLE variable name (event + gating)
pub priority: u32, // 0 = highest priority per IEC 61131-3
pub programs: Vec<ProgramId>,
pub fb_instances: Vec<ValueRef>,
}
pub struct ResourceRunner<C: Clock + Clone> {
runtime: Runtime,
clock: C,
cycle_interval: Duration,
}
impl<C: Clock + Clone> ResourceRunner<C> {
pub fn tick(&mut self) -> Result<(), RuntimeError> {
// single deterministic cycle (tests)
Ok(())
}
pub fn spawn(self, name: &str) -> ResourceHandle<C> {
// start dedicated OS thread
}
}
Implementation notes:
- The SINGLE input is sampled from the current variable state; a transition 0 -> 1 enqueues exactly one activation.
- On task registration, the runtime initializes the previous SINGLE value to avoid a spurious edge on the first cycle.
- Periodic scheduling uses Clock::now() and the task interval (nanosecond Duration).
- Task readiness and elapsed-time accounting use only the injected monotonic
Clock; the exact readiness, discontinuity, and overrun contract is defined
below. Civil wall-clock changes do not make a task ready.
- Inputs are latched at the start of each scheduler cycle; outputs are committed after all ready tasks complete.
- The maximum number of tasks per resource and minimum interval resolution are implementer-specific and are reported by the runtime configuration.
- The resource loop maintains a RUNNING/FAULT/STOPPED state and halts on faults.
Task readiness and overrun accounting¶
A1 implementation: the nominal-deadline rule below is implemented in source under specification 34 Scope A, A1 before its first validation batch and accepted scheduler baseline. The pre-A1 code used the sampled-time baseline; validation status remains in the portability checklist. Source implementation is not validation evidence. IEC 61131-3 Ed.3 §6.8.2(b) requires periodic scheduling at the specified interval; §6.8.2 permits implementation-specific interval resolution and execution later than the scheduled instant.
For each scheduler sample, the portable readiness boundary observes one
SINGLE value and one logical now. A FALSE-to-TRUE SINGLE transition makes
the task due at now, updates the saved edge state, and does not advance the
periodic baseline or overrun count. A held TRUE value does not retrigger and
suppresses periodic readiness; a subsequent FALSE sample only rearms the next
rising edge. A non-positive interval does not schedule periodic work. Valid
IEC task configuration uses zero for disabled periodic scheduling and a
positive duration for periodic scheduling; a negative portable metadata value
is conservatively treated as disabled. (IEC 61131-3 Ed.3 section 6.8.2 a-b.)
Task registration records the current logical time as the initial periodic
baseline. After a periodic activation, last_run denotes the latest nominal
deadline accounted for, not the sampled execution time. With SINGLE FALSE and
a positive interval, a task is not due until now is at least
last_run + interval. A backward logical-clock sample does not move last_run,
create an overrun, or make the task due; periodic readiness resumes only when a
later sample reaches the next deadline relative to the unchanged baseline.
When exactly one interval is due, the returned due time is that nominal
deadline, the missed-interval count is zero, and last_run advances to that
deadline. A late sample does not move later deadlines: the task is selected in
the first cycle sampled at or after its deadline and executes in that cycle's
normal priority order, subject to the existing fault/STOP policy.
If a forward jump spans n > 1 complete intervals, the scheduler emits one
activation rather than replaying work. Its due time remains the first nominal
deadline, the per-sample missed count is n - 1, the cumulative overrun count
adds that value with saturation, and last_run advances to the last nominal
deadline at or before now: the first due deadline plus n - 1 intervals.
Equivalently, with the previous baseline b, compute
n = saturating_sub(now, b) / interval and set
last_run = saturating_add(b, saturating_mul(n, interval)) when periodic work
is due. Deadline arithmetic saturates at the representable signed-nanosecond
bound. Missed activations are dropped, counted, and never replayed. Event
readiness never changes those periodic counters. These due-time and accounting
rules are truST bookkeeping beyond the IEC task-trigger definition; they make
host-pause, simulation, and test-clock behavior deterministic. They are
specified here as a truST runtime contract, not as an IEC deviation.
For a task registered at 0 ms with a 25 ms interval and samples exactly every 10 ms, nominal deadlines 25, 50, 75 and 100 ms produce activations at 30, 50, 80 and 100 ms. The periodic baseline becomes 25, 50, 75 and 100 ms respectively; no interval is missed. This preserves the configured mean period instead of the pre-A1 sampled-time implementation's 30 ms period. The sampling lateness bound applies to this fixed-grid case; actual task start and completion also depend on load, priority and platform delays.
Resource-runner cycle transaction¶
One resource tick samples the current injected clock and task inputs, then executes each due periodic or rising-edge event program once in the deterministic scheduler order. In the reviewed combined case, the initial zero-time tick executes neither program, the tick at 10 ms executes the 10 ms periodic program and one rising-edge event activation, and the tick at 20 ms executes only the periodic program after the event input falls. These exact-deadline observations are unchanged by the baseline correction.
A backward manual-clock sample does not replay periodic work or create an overrun. A later sample that reaches the next deadline resumes from the unchanged periodic baseline. A forward jump spanning multiple complete intervals executes the periodic program once and records the remaining intervals as missed, as defined by the readiness contract above. Under the nominal-deadline rule, a 10 ms task first sampled at 35 ms reports due time 10 ms, one activation and two missed intervals, advances its baseline to 30 ms, and is next due at 40 ms. The pre-A1 sampled-time implementation instead advanced that baseline to 35 ms and is next due at 45 ms.
If due task execution returns an error, the tick returns that typed error and
latches the runtime fault state. A later tick rejects as
RuntimeError::ResourceFaulted until an explicit recovery transition clears or
replaces that state. This focused contract does not itself define the state
after the fault; section 6.6 (state after a faulting scan) does.
For the same source, initial runtime state, manual-clock samples, and input
transitions, the ordered RuntimeEvent vector is reproducible. Event payload
debug formatting, wall-clock timing, OS-thread scheduling, and cross-version
trace compatibility are outside this equality contract.
On Unix, the runtime's graceful-shutdown signal set is exactly SIGINT and
SIGTERM. Signals outside that set are not translated into a runtime stop by
the installed signal source. Their operating-system disposition is outside
this runtime contract.
6.3 Timer System¶
Implements IEC 61131-3 timers: TON (on-delay), TOF (off-delay), TP (pulse).
All timers use Clock::now() for elapsed time calculation. Timer instances are evaluated when their owning program or task-associated function block executes; no background threads or interrupts are required.
The normative TP, TON, TOF, and *_LTIME scan-step state machines are defined
in Standard Function Blocks, section 5.
The implementation-owned clock, call, preset, and restart boundaries are
recorded in docs/IEC_DECISIONS.md. Timer traces observe executed calls only;
they do not claim that outputs change continuously between scan steps.
Timer Restart and Time-Base Contract¶
A newly constructed runtime starts its monotonic time at zero. An in-process warm or cold restart does not create a new clock epoch: it preserves the runtime's current monotonic time while reinitializing non-retained timer instances and task timing baselines at that value. The first executed timer call after restart contributes zero elapsed time. Subsequent elapsed time is measured from the preserved post-restart baseline, so a restart cannot make the runtime clock move backward or charge pre-restart elapsed time to a new timer instance.
6.4 Process Image¶
Memory-mapped area for inputs (%I), outputs (%Q), and markers (%M).
pub struct IoInterface {
inputs: Vec<u8>,
outputs: Vec<u8>,
memory: Vec<u8>,
}
Sizes are derived from compiled program metadata at load time. On embedded targets, static sizing may be used, but the logical model remains the same.
The process image is owned by a single resource thread; no internal locking is required. A runtime runs one resource, and cross-resource data sharing is later work (section 6.10). External I/O exchange (Modbus, etc.) reads/writes to this image at cycle boundaries.
Process-image bounds, reads, snapshots, and metadata application¶
Each %I, %Q, and %M byte area has an independent maximum length of
16 MiB. A resize, bytecode metadata declaration, source binding, or concrete
write whose addressed window exceeds that limit is rejected before allocating
or mutating the affected area. A failed metadata application is transactional:
resource selection, task/program references, all three area lengths, and I/O
bindings are validated before the executable state or process image changes.
The rule that a project's resource is the program's RESOURCE is in section
6.8.1d.
Reading an unallocated byte or the unallocated portion of a multi-byte value returns zero bytes without growing the area. An unallocated fixed-width string window therefore reads as an empty string. A fixed-width string write whose payload is larger than its declared byte window returns overflow before allocation or partial mutation.
For an in-range concrete address, a successful typed write followed by a typed read of the same address returns the written value. Bit access updates and reads only the addressed bit within its byte. Word access uses the process image's little-endian byte representation and returns the same 16-bit value.
An I/O snapshot is produced after output and marker bindings have been
committed for the cycle. Each entry preserves its area, complete address,
optional display name, optional driver/source description, and optional
declared type. Typed snapshot values are decoded according to that declared
type, including REAL, TIME, and fixed-width STRING; a conversion failure
is represented as an entry error rather than a fabricated untyped value.
Process-image interface and binding transaction¶
A new process-image interface has empty %I, %Q, and %M byte areas, no
bindings, and no hierarchical values. Resizing validates all three requested
area lengths before changing any area. Shrinking truncates only the removed
suffix; growing preserves the retained prefix and zero-initializes every new
byte. A failed resize leaves all three areas byte-for-byte unchanged.
Concrete bit, byte, word, double-word, and long-word access uses the selected area and byte offset. Multi-byte values are little-endian. Bit writes preserve the other seven bits in the addressed byte. A successful write changes no other area. A wrong value kind, wildcard address, zero-length byte-string window, overflowing address calculation, or window beyond the 16 MiB cap is rejected before allocation or mutation.
A fixed-width string write accepts valid UTF-8 whose byte length does not exceed the declared nonzero window. It writes the payload then zero-fills the remaining window, removing bytes from a previous longer value. Reading stops at the first NUL byte; invalid UTF-8 before that terminator is a type error. Unallocated portions of an in-range read are zero-filled conceptually without growing the process image.
Hierarchical addresses are stored outside the flat byte areas and are keyed by their complete area, size, bit, and path identity. Reading an absent hierarchical key fails rather than fabricating zero. Writing one hierarchical key does not allocate a flat process-image byte or alter another hierarchical key.
Bindings retain insertion order. Snapshot rows are partitioned into input, output, and memory lists while retaining insertion order within each area. Named bindings use the declared name as their display name; reference bindings remain unnamed unless an explicit display name was supplied. Recomputing binding provenance replaces every row's previous source with the callback result, including clearing a prior source when the callback returns none. A wildcard binding appears as unresolved and is never read as a concrete zero.
Start-of-cycle input synchronization is transactional across every eligible
%I and %M binding. The interface first reads and type-checks every source
value and resolves every destination. Only after the complete batch succeeds
are values committed to program storage. A read, conversion, undefined target,
or null-reference failure leaves all bound program values unchanged. %Q
bindings are ignored by this phase.
End-of-cycle output synchronization has the symmetric all-or-nothing rule for
eligible %Q and %M bindings. It resolves and converts every program value
into a staged process image before replacing output, memory, or hierarchical
state. Any missing value, null reference, conversion failure, bounds failure,
or type mismatch leaves all three interface states unchanged. %I bindings
are ignored by this phase.
Typed floating-point egress¶
At end-of-cycle synchronization, typed %Q and %M bindings declared as
REAL or LREAL accept only values that are finite at the declared width. A
value converted to REAL must also remain finite after basic-single
narrowing. The runtime preflights every eligible output and marker binding
before mutating either process-image area; any rejected value returns an
IoDriver error, leaves the entire pending %Q/%M image unchanged, and
prevents the normal driver-output commit. The runtime does not clamp,
normalize, substitute, or emit non-finite IEEE bits. Configured safe-state
handling after the resulting cycle fault is a separate policy boundary.
6.5 I/O Drivers¶
I/O exchange is explicit and deterministic: inputs are read into the input image at the start of each resource cycle, and outputs are written after all ready tasks complete.
Marker bindings (%M) are synchronized with program storage at both cycle boundaries:
- Start of cycle: %M process image -> bound variables (same phase as %I input latch).
- End of cycle: bound variables -> %M process image (same phase as %Q output commit).
A bound variable starts from its initialized value at a cold restart and from its
retained value at a warm restart (IEC 61131-3 Ed.3 §6.5.1, §6.5.6.1 and Figure 9):
after runtime construction and after every restart, before the first cycle, each
marker binding is committed from its bound variable. The first start-of-cycle latch
therefore reads the declared or VAR_CONFIG initial value instead of a zero or
pre-restart image. A value written to the memory image after that commit, for
example by a client before the first cycle, is latched as before.
pub trait IoDriver: Send {
fn read_inputs(&mut self, inputs: &mut [u8]) -> Result<(), RuntimeError>;
fn write_outputs(&mut self, outputs: &[u8]) -> Result<(), RuntimeError>;
fn health(&self) -> IoDriverHealth { IoDriverHealth::Ok }
}
Multiple drivers may be composed (e.g., fieldbus + simulated I/O). The resource scheduler owns the driver(s) and invokes them at cycle boundaries.
Composed drivers retain their configured order. On a successful boundary, each
driver reached by the composition receives exactly one input-read attempt
before task execution and one output-write attempt after task execution. An
earlier driver error short-circuits that boundary. %I and %M bindings are
loaded before ready tasks run; %Q and %M bindings are committed after those
tasks finish; the cycle I/O snapshot is emitted only after that commit. Arrays,
structures, and function-block instance bindings use their flattened declared
layout. An unresolved %I*, %Q*, or %M* binding must be resolved by a
matching-area VAR_CONFIG entry before runtime construction and is rejected
in disallowed declaration sections or on an area mismatch.
The built-in driver registry exposes the stable canonical names analog, can,
counter, ethercat, gpio, loopback, modbus-rtu, modbus-tcp, mqtt, pwm,
and simulated in sorted unique order. Accepted aliases resolve to the canonical name before
validation or construction; this includes sim/noop, modbus_rtu,
modbus_tcp, mqtt-tcp, and ether-cat/ecat. The explicit driver name none selects no driver.
Driver lookup trims surrounding whitespace and is ASCII-case-insensitive.
Unknown and empty names are configuration errors. none is reserved and
cannot be replaced by a registered driver.
Registry validation and construction are separate boundaries. Validation
invokes the selected driver's parameter validator only; it must not construct
a driver, open a device/socket, start a worker, or perform a protocol
connection. Construction runs only after successful validation and returns the
canonical name in the built specification. A registered alias cannot shadow a
canonical name, and an alias whose target is absent does not create a usable
entry. Canonical enumeration never includes aliases, empty names, or none.
Operator-facing I/O provenance uses the same canonicalization and alias table
as driver selection. Internal %M bindings always report Internal memory.
With no enabled driver, an external binding has no asserted source. With
exactly one enabled driver, its protocol-specific source is shown; disabled
drivers do not affect that choice. With two or more enabled drivers, the
conservative label is Multiple I/O drivers because whole-image composition
cannot assign one driver to the row. Modbus labels use the direction-specific
configured base plus the addressed word offset, MQTT labels use the
direction-specific topic, and GPIO labels use the addressed bit number.
Provenance is a configuration projection and never claims a connection,
successful exchange, or healthy hardware.
The GPIO driver accepts chardev/libgpiod (defaulting to
/dev/gpiochip0) and the compatibility sysfs backend (defaulting to
/sys/class/gpio). Input entries require concrete %IX bit addresses and
output entries require concrete %QX bit addresses; wildcard, non-bit,
wrong-area, and nested addresses make driver construction fail. The current
construction order does not claim that backend configuration is side-effect
free before this mapping rejection. GPIO read or write failure sets faulted
driver health with the original error. A configured watchdog/fault safe-state
output is written through the GPIO driver and is successful only under the
safe-state confirmation rule below.
GPIO parameters themselves follow these fail-closed rules:
- the parameter root is a table; absent
inputsoroutputsmeans an empty list, while a present list and every list entry must have the declared TOML shape; - the backend name is ASCII-case-insensitive: absent,
chardev, andlibgpiodselect the character-device backend, whilesysfsselects the compatibility backend; no other name is accepted; - every entry has a string
addressand aline: a nonnegativeu32on a computer, while a chip'sio.tomlnames a pin of the chip (specification 34 TARGET-01), such asPD12or a GPIO number, which a computer refuses when the driver starts because on this computer a line is a number;pinis a compatibility alias used only whenlineis absent; invertand outputinitialdefault to false and accept booleans, integer zero/nonzero, or stringstrue/false/1/0;- input
debounce_msdefaults to zero and accepts a nonnegative integer or unsigned decimal string; and - one physical line may appear only once across all inputs and outputs, and one process-image bit may appear only once within its input or output list.
Parameter validation performs all mapping-shape and collision checks without opening a GPIO device or exporting a sysfs line. A simple bit address maps byte and bit directly, with bit numbers restricted to 0 through 7. Internal process-image helpers return a configuration error for an out-of-range byte or bit and never panic; writes change only the selected bit.
On input handoff, entries are evaluated in declaration order. Each raw value is
inverted when configured, then debounced, then written to its %I bit without
changing other bits. The first debounced sample establishes state. A change
before the configured delay retains the prior state; a change at or after the
delay is accepted. On output handoff, the selected %Q bit is inverted when
configured and sent only when it differs from the last successfully written
level. Failed writes are not memoized and are retried on the next handoff.
Any backend, process-image bounds, or malformed sysfs value failure
short-circuits the handoff and records faulted health containing the original
error. A later complete successful handoff restores healthy status. Sysfs input
values are exactly trimmed 0 or 1; other content is rejected rather than
being interpreted as energized. Synthetic filesystem tests prove parsing and
sysfs file semantics only. They do not prove pin ownership, voltage, wiring,
kernel driver operation, or device-in-the-loop behavior; those claims require
the separately reviewed real-hardware case and artifact.
Analog, PWM, counter and CAN drivers¶
The drivers analog, pwm, counter and can give a project the other I/O
of a chip with the same keys and meaning on every runtime (specification 34
TARGET-01; the owner's decision of 16 October 2026 that chips work exactly as
trust-runtime). A line names the runtime's own hardware: on a chip a pin or
a peripheral that its chip file lists (GPIO2, TWAI0); on Linux a kernel
device and channel, as below. A runtime without such hardware, macOS and
Windows among them, refuses the driver when it starts, with the driver's name
and the reason (specification 34 ARCH-13). Their parameters are checked
fail-closed, with the key and the reason: the root is a table, an absent list is
empty, an unknown key is refused, every address is concrete, flat and of the area
and size the driver names, a line is used once across the driver's entries, and
the entries of one list use bytes of their own, so no entry's bytes overlap
another's (%IW0 takes bytes 0 and 1, so %IW1 beside it is refused, and %IW2
is not). Numbers are JSON integers and words are exact, except on_error, which
reads as the Modbus driver's does. Each input is read once at the start of each
cycle, and each output is written after the tasks ran, as for every driver; an
output whose value did not change since its last good write is not written
again. A stop or a fault writes the [[io.safe_state]] values as for every
driver (§6.6), and a chip's terminal fault path, which resets the chip, drives
each output to its safe value and otherwise to 0 (specification 34 TARGET-08).
analog:
inputs = [{ address, line }]:addressis a%IWword, and the input is the voltage at the line in millivolts as anINT. A chip converts with its own calibration at the attenuation that measures its whole range (specification 34 Appendix D names each chip's range). On Linux the line is an IIO input channel,iio:device0/in_voltage1, and the value is(raw + offset) × scalefrom the channel's_raw,_offsetand_scalefiles, or the device's sharedin_voltage_offsetandin_voltage_scale(_scalein millivolts per count; an offset absent from both is 0), read once when the driver starts and rounded to the nearest millivolt. A differential channel (in_voltage0-voltage1) is refused.outputs = [{ address, line, initial }]:addressis a%QWword, the millivolts the line is driven to as anINT, frominitial(default 0) until the program writes it. On Linux the line is an IIO output channel,iio:device0/out_voltage0, written asround(mV / scale) − offset. A chip without a digital-to-analog converter refuses an output, with the reason.- A value outside what the hardware converts is limited to its range; on Linux
the range of an output is its channel's
_raw_available, and without that file a code the converter refuses faults the driver.
pwm:
outputs = [{ address, line, frequency_hz, initial }]:addressis a%QWword, the duty cycle in hundredths of a percent as aUINTfrom 0 to 10,000, where a larger value drives 100 %;frequency_hzis required, an integer from 1 to 1,000,000;initialis the duty cycle before the program writes it, default 0. The driver makes the frequency with the finest duty resolution the hardware has at it and refuses a frequency the hardware cannot make, with both numbers. On a chip, outputs of one frequency share a timer, and specification 34 Appendix D names how many timers and outputs each chip has. On Linux the line is a sysfs PWM channel,pwmchip0/pwm1, which the driver exports and sets throughperiod(1,000,000,000 /frequency_hznanoseconds, rounded down),duty_cycleandenable.
counter:
inputs = [{ address, line, mode, edge, line_b }], withmodepulse(the default) orquadrature.pulse:addressis a%IDdouble word, the number ofedges atline(rising, the default,fallingorboth) since the driver started, as aUDINTthat wraps from 4,294,967,295 to 0.line_bis refused.quadrature:lineandline_bare an encoder's A and B; the count rises by one at each edge of either line while A leads B and falls by one while B leads A (four counts per cycle of A), as aDINTthat wraps.edgeis refused. A chip counts B on a pin of its own and requiresline_b.- A chip counts in hardware with a 16-bit counter and adds the change since its
last read at each cycle start, so at most 32,767 counts may arrive between two
cycles; specification 34 Appendix D names how many counters each chip has. On Linux the line is a
counter device's count,
counter0/count0, whosefunctionthe driver sets toincrease(with the synapse action ofedge) orquadrature x4and whosecountit reads as the change since its last read, across the wrap at itsceiling, for an encoder the shorter way round;line_bis refused there, as the device's own signals are the encoder's lines.
can (CAN 2.0, ISO 11898-1):
lineis the CAN controller: on a chip one of its chip file's controllers, such asTWAI0, withtxandrxnaming the pins that go to the transceiver; on Linux a SocketCAN interface such ascan0, wheretxandrxare refused.bitrateis required: 10,000, 20,000, 50,000, 100,000, 125,000, 250,000, 500,000, 800,000 or 1,000,000 bit/s, the rates of CiA 301. A chip refuses a rate its clock cannot make. On Linux the interface's rate belongs to the operating system (ip link): the driver reads it, refuses another rate with both numbers, and refuses an interface that is down; an interface that has no rate, such asvcan, is taken.receive = [{ id, extended, address, length, timeout_ms }]: each data frame withid(11 bits, or 29 bits withextended = true) writes its data bytes to thelengthbytes (0 to 8) of the input image fromaddress, a%IBbyte, and zeros where its data length code does not reach; the newest frame before a cycle start is the one read. Withtimeout_ms(1 to 60,000), no such frame for that long is the errorno frame <id> for <timeout_ms> msunderon_error, as for Modbus:faultfaults the driver's health and the resource,warnandignoredegrade the health; the next frame ends it. A receive's error concerns the input path only, so it never leaves a safe-state handoff unconfirmed.transmit = [{ id, extended, address, length, period_ms }]: everyperiod_ms(1 to 60,000) the driver sends a data frame withidand thelengthbytes of the output image fromaddress, a%QBbyte; the safe state's values are sent the same way.on_errorisfault(the default),warnorignore, as for Modbus.- A controller that goes bus-off makes the driver's health faulted with
bus offand rejoins after the bus recovery of ISO 11898-1, without a restart. On Linux the interface rejoins by itself only with itsrestart-msset (ip link set can0 type can restart-ms 100); with 0 it stays bus-off, as the driver reports, until the interface is restarted. - Two receives, or two transmits, with one
idandextendedare refused; a receive and a transmit may share one.
Process-image drivers must keep scan-cycle methods bounded. Drivers with
blocking wire protocols may own background workers, but the IoDriver boundary
remains the cycle handoff: read_inputs copies the latest worker snapshot or
returns the configured policy result, and write_outputs hands off the latest
desired output without waiting for protocol round trips. Worker health is
projected through the existing IoDriverHealth surface rather than a parallel
status model. Output handoff is level/latest-value semantics for %Q, not an
edge or pulse delivery guarantee.
The native Modbus worker regression proves causal independence from a protocol response: a real peer receives a request, withholds its reply, and the scan call returns under its configured policy before that reply is released. Its finite harness timeout is shorter than the transport timeout, so waiting for transport timeout cannot satisfy the assertion. Elapsed wall time is diagnostic evidence; it does not certify a hard latency bound on an unqualified shared operating system. Target-specific timing qualification remains separate.
Modbus default-fault and deadline integrity¶
The Modbus background worker uses a fixed scan-side deadline for input refresh
and output handoff. Missing that deadline applies the configured on_error
policy at the synchronous IoDriver boundary:
- With
fault, an incomplete input refresh setsFaulted, recordsmodbus input refresh pending, returnsIoTransport, and leaves the caller's input bytes unchanged even when an older accepted snapshot is cached. - With
warnorignore, the same incomplete input refresh setsDegraded, records the pending-refresh diagnostic, and returns success. If an accepted snapshot exists, it may be copied according to the normal snapshot-shaping rules; absence of a snapshot does not invent input bytes. - With
fault, an output handoff that remains incomplete at the deadline setsFaulted, recordsmodbus output handoff pending, and returnsIoTransport. Withwarnorignore, it setsDegradedand returns success. - A timed-out output was already accepted into the asynchronous level/latest handoff. Deadline reporting does not cancel that pending or in-flight output; a later worker result governs its eventual transport outcome.
The positive controls for this deadline contract are a completed default register loopback through the worker and a runtime safe-state handoff confirmed by the worker and observed at the Modbus peer. Cold-start coverage must exercise all three policies before any first snapshot exists and must leave caller bytes unchanged when no accepted snapshot is available.
These deadline rules do not replace the separate policy projection for a completed transport operation. They define only the scan-side result while the requested worker sequence remains incomplete. This is a truST Modbus runtime contract outside IEC 61131-3, so it is neither an IEC decision nor an IEC deviation.
Driver error handling is configurable per driver:
- fault: return an error and fault the resource.
- warn: keep the resource running; driver health becomes degraded.
- ignore: keep the resource running; error is suppressed (health may still degrade).
Driver health is exposed via ctl status and the TUI.
Safe-state handoff confirmation¶
Ordinary scan-output writes use the configured driver error policy and the level/latest-value worker handoff described above. Applying a configured safe state has a stronger success boundary:
- The runtime first applies the configured safe values to the output process image and then attempts the resulting output image on every configured I/O driver.
- A driver's safe-state attempt is confirmed only when
write_outputsreturns successfully and the driver's immediately observed output-path health isOk.DegradedorFaultedoutput-path health means the physical handoff is unconfirmed, even whenon_error = "warn"or"ignore"made the ordinary driver call return successfully. Output-path health is the driver's health, except that input-path conditions degrade or fault the reported driver health but not the output path, so they never unconfirm a safe-state handoff. For MQTT these are stale or missing input and incoming-queue overflow (specification 32 §8, specification 34 §11.1). - A worker-backed driver whose handoff is queued, reconnecting, timed out, or otherwise pending must therefore return a safe-state error at the runtime boundary. A later asynchronous delivery does not retroactively turn that failed attempt into a confirmed safe state.
- The runtime attempts the safe-state write on the remaining configured drivers after one driver fails, but returns an error for the overall operation. The first failure identifies the affected driver and cause.
- A deliberate stop with an unconfirmed configured safe state ends in
Faulted, notStopped, and exposes the failure throughlast_error. When safe-state application is part of fault handling, the original root fault is retained and the reported error/event identifies the safe-state failure.
This confirmation rule does not add an unbounded protocol wait. Worker-backed drivers retain their existing bounded handoff deadline; exceeding that deadline is an unconfirmed safe state. With an empty safe-state configuration, the runtime still performs no physical output write as specified in section 6.6. When no I/O driver is configured, applying values changes only the in-memory process image and makes no physical-delivery claim.
The OPC UA client transport-to-worker event handoff is bounded. When its fixed-capacity queue is full, publishing an additional sample, connection, or session event returns failure immediately rather than blocking or allocating an unbounded queue. After the worker drains pending events, the same sink can accept a later event. A rejected event is not claimed delivered.
OPC UA server surface, security, and cold start¶
The runtime's OPC UA server publishes PLC snapshot variables with CurrentRead access only. The periodic publisher refreshes those values from runtime snapshots; client writes are rejected because no transactional write-back path to PLC storage is defined. A future writable server surface requires an explicit allowlist, authorization, type validation, scan-boundary application, and visible write result before it may advertise CurrentWrite.
The server value projection is closed over BOOL; signed 16-, 32-, and 64-bit
integers; unsigned 16-, 32-, and 64-bit integers; REAL; LREAL; and
STRING. Each value retains the corresponding OPC UA Boolean, integer, Float,
Double, or String scalar. A declared enum exports its selected variant name as
an OPC UA String. Null, references, time values, arrays, structures, and other
unsupported runtime values are omitted rather than invented as another scalar
type.
The secure server profile defaults to Basic256Sha256,
SignAndEncrypt, and anonymous access disabled. Policy None is valid only
with message mode None; a signing or encryption policy requires a compatible
non-None mode. Configured authentication and certificate-trust rules are
enforced by the wire server before a client may read a node.
Server construction and listener startup do not depend on the first runtime snapshot. Startup fails with a stable error, and reports no running server, when the configured listen address is already in use or the server stops before it serves requests; another process answering on the same port never counts as this server being ready. Before a snapshot exists, nodes have no fabricated ready value. After a snapshot becomes available, a reference client may read every exported supported scalar with CurrentRead access. Loopback/reference-client proof establishes interoperability for the exercised profile only; it does not establish external-vendor or production-certificate interoperability.
Floating-point boundary admission policy¶
IEC 61131-3 Ed.3, Section 6.4.2.1, Table 10 defines REAL and LREAL
using the IEC 60559 basic single- and double-width formats and leaves results
involving infinity or not-a-number implementer-specific. Section 6.6.2.5.15,
Table 39 defines IS_VALID so program logic can distinguish finite values from
NaN and infinity. Those provisions do not define how an external host or
protocol admits a value into a PLC process image. truST therefore applies the
following fail-closed product contract:
| Boundary | Typed non-finite input | Required rejection effect |
|---|---|---|
Typed %I snapshot |
REAL/LREAL IEEE bits decode to NaN or either infinity |
Return an I/O error before exposing a typed snapshot value; do not normalize or substitute a value. |
Modbus input_points |
An f32 register value, or its scaled engineering value, is non-finite |
Reject the complete mapped read before changing any byte of the caller's input image. Previously accepted process-image bytes remain unchanged. |
MQTT input_points |
An f32 text, JSON, or binary payload, or its scaled engineering value, is non-finite |
Reject the complete received payload batch before changing the caller's input image or the driver's last accepted mapped snapshot. A later valid payload must not expose values from the rejected batch. |
| OPC UA client points | A configured Float/REAL or Double/LREAL sample is non-finite |
Mark the point faulted and retain the previous PLC value, as specified below. |
| ADS client/server points | A scalar or array REAL/LREAL value is non-finite |
Reject before cache acceptance, write queuing, or PLC storage mutation, as specified below. |
| Runtime mesh subscription | Numeric conversion to the configured REAL/LREAL target is non-finite |
Reject before queuing or applying the local update; retain the previous PLC value. |
| Managed local debug write/force | Input for a declared REAL/LREAL address is non-finite or overflows the declared width |
Return a failed DAP response and preserve both value and force state, as specified in 6.9.1. |
Checked embedding engineering writes (write_global, write_instance, write_reference, write_access) |
A declared REAL/LREAL value, recursively through arrays/structures, is non-finite |
Return runtime_non_finite_value before destination or process-image mutation; preserve the installed state. Internal IEC assignments remain governed by their arithmetic contract. |
| File-backed retain save/load | A scalar, array element, or structure field is non-finite | Reject the complete save or load transaction, as specified in 6.7. |
simulation.toml coupling threshold |
The configured threshold is non-finite | Reject the configuration before activation, as specified in 6.9.2. |
For a declared REAL, validation occurs after any scaling or narrowing to IEC
basic single width; a finite wider intermediate that becomes infinite at that
width is rejected. LREAL is validated at basic double width. Rejection never
clamps, normalizes, or substitutes a default. Error-policy handling may decide
whether the resource faults or reports degraded health, but it cannot turn the
rejected value into an accepted process value.
Raw Modbus register mode, raw MQTT byte topics, and integer-address control
operations carry untyped bits and cannot classify an IEEE payload by
themselves. They do not bypass this contract: once those bits cross a declared
REAL/LREAL binding, the typed process-image snapshot or egress preflight
must reject a non-finite representation. This boundary policy does not change
in-process IEC arithmetic, IS_VALID, subnormal, or signed-zero semantics.
Built-in drivers
- Modbus/TCP
paramskeys:address(required: an IPv4a.b.c.d:portor an IPv6[addr]:portliteral, port 1 to 65535; a chip whose network is IPv4 refuses an IPv6 server),unit_id(default 1),input_startandoutput_start(default 0),timeout_ms(default 500, at least 1),on_error,input_function,output_function,input_pointsandoutput_points. An unknown key is refused, as for every driver of §6.5, with the key and the reason (modbus-tcp: <key>: <reason>).- Default profile uses input registers (FC04) for input image.
- Default profile uses multiple holding-register writes (FC16) for output image.
- Explicit
io.tomloptions can selectread_coils(FC01),read_discrete_inputs(FC02),read_holding_registers(FC03),read_input_registers(FC04),write_single_coil(FC05),write_single_register(FC06),write_multiple_coils(FC15), orwrite_multiple_registers(FC16). - Optional
input_pointsandoutput_pointsmap individual coil/register addresses to process-image offsets withbool,u16,i16,u32,i32, orf32types, linearscale/offset, and Modbusbyte_order/word_order. - Register payloads are big‑endian (high byte first).
- Coil payloads are Modbus-packed with the first process-image bit in the least-significant bit of the first byte.
- Point-map numeric values are stored in the runtime process image as
little-endian bytes after scaling so
%IW/%IDbindings read the same value that the map produced. - Register quantity is derived from the process image size (
ceil(bytes / 2)). - The protocol is one implementation on every runtime, the
trust-modbuscrate (Modbus Application Protocol V1.1b3, Modbus Messaging on TCP/IP V1.0b and Modbus over Serial Line V1.02; checklist S7 and S9). A request keeps the specification's quantity limits: 2,000 bits for FC01 and FC02, 125 registers for FC03 and FC04, 1,968 bits for FC15 and 123 registers for FC16. A process image larger than one request is exchanged in several requests of at most that many, in address order, within the one exchange of a cycle, as a professional client does; a range that reaches past address 65,535 is refused at configuration. - Every response is checked before its data is taken: the function is the
request's; the byte count is the one the requested quantity makes; a write
echoes its address and its value or quantity. A Modbus TCP response has
protocol 0, a length from 2 to 254 that its bytes match, and the request's
transaction id; a response with another transaction id is discarded and the
client reads on until its own or its timeout (V1.0b §4.4.1.3). The unit id of
a TCP response is not compared, as V1.0b lets a client ignore it for a server
directly on TCP/IP. An exception response is the typed exception error with
its code and the specification's name for it. A response that fails a check is
a transport error under
on_error, and no byte of it reaches the image. - Runtime exchange is worker-backed: scan-cycle reads/writes use bounded snapshot/handoff state while Modbus TCP connect/read/write round trips happen on the Modbus worker.
- A completed worker read preserves its typed protocol error at the scan-cycle
boundary. In particular, a Modbus exception remains an
IoAddresserror and is not replaced by a generic unavailable-snapshot transport error. - Function selection is closed over FC01, FC02, FC03, and FC04 for input and FC05, FC06, FC15, and FC16 for output. An unknown or direction-incompatible function is rejected during configuration.
- A coil point is boolean and does not accept numeric scaling. A register point uses a declared supported numeric type; scaling must be finite and invertible for the requested direction. Invalid point type, address, function, or scaling is rejected before the worker starts.
- A completed transport failure follows
on_error:faultreturns the typed error and faults health, whilewarnandignorereturn degraded success. This policy never rewrites a Modbus exception into a generic transport error. - Reconnect uses a non-zero bounded backoff and cannot spin on an unavailable endpoint. Dropping the driver requests worker shutdown and returns within the bounded driver-drop deadline even when the worker is awaiting its first response. This return-time contract does not claim that the detached worker thread has already joined; joined worker shutdown remains open proof debt.
Modbus declared-width floating-point finiteness¶
For an f32/REAL point, finiteness is checked again after scaling or inverse
scaling is narrowed from the internal f64 calculation to the declared
f32 width. A finite f64 result that becomes non-finite at f32 width is
rejected before process-image mutation or Modbus wire encoding; it must not
become an infinite REAL value or register payload. This is a truST Modbus
product and safety contract outside IEC 61131-3, not an IEC decision or
deviation.
Typed protocol point-map integration contract¶
Typed point maps bind one protocol scalar to one bounded process-image scalar. They do not reinterpret arbitrary trailing bytes, merge overlapping definitions, or use a partially converted batch as PLC input.
The common scalar set is bool, u16, i16, u32, i32, and f32.
Documented IEC-style aliases (boolean, word, int, dword, dint, and
real) and explicit-width aliases select the same type case-insensitively.
Integer conversion rounds to the nearest integer with half-way values away
from zero, then checks the declared range. F32 conversion must remain finite
after narrowing. Inbound scaling is engineering = raw * scale + offset;
outbound scaling is raw = (engineering - offset) / scale. Scale must be
finite and non-zero and offset must be finite. Boolean points do not accept
scaling.
Process-image numeric values are little-endian and occupy exactly two or four
bytes according to their declared type. A Boolean occupies one selected bit;
its bit index is 0..=7, and writes preserve every other bit in the byte.
Every offset-plus-width calculation is checked for arithmetic overflow and
image bounds before mutation. A failed conversion or range check leaves the
complete destination bytes unchanged.
Within one input or output map, two point definitions must not claim the same
protocol identity or overlapping process-image storage. Boolean bits in the
same byte are distinct storage only when their bit indices differ. Modbus
register ranges account for the type's complete one- or two-register width and
the selected function/table. MQTT protocol identity is specified by
32-mqtt-io.md.
An input and output may use the same process-image offset because %I and
%Q are separate images. Ambiguous duplicates or overlaps are configuration
errors before a worker or transport is started.
MQTT is a communication protocol owned by
32-mqtt-io.md. This runtime-engine document owns only its
integration boundary: startup resolves symbolic tags and installs all
process-image bindings atomically before the protocol worker starts; the worker
cannot access program storage or resolve symbols; scan-cycle calls exchange
bounded snapshots and handoffs; and runtime I/O snapshots retain the declared
PLC type metadata required by control and debug surfaces.
For Modbus:
- FC01/FC02 points are Boolean and FC03/FC04 points are numeric; FC05/FC15 outputs are Boolean and FC06/FC16 outputs are numeric;
- omitted type defaults to Boolean for bit functions and U16 for register functions;
- FC06 accepts only one-register U16/I16 values;
- byte order applies inside every 16-bit register and word order applies
between the two registers of a 32-bit value; both default to big-endian and
accept the documented
big/beandlittle/lealiases; and - a decoded register scalar consumes exactly the declared register byte count. A truncated or oversized buffer is rejected rather than partially decoded.
These point-map rules are truST protocol/product behavior outside IEC 61131-3. Driver error policy may classify the resulting error, but cannot turn an invalid configuration, malformed scalar, out-of-range value, or partial batch into accepted PLC data.
- MQTT I/O integration
- MQTT configuration, topics, mapping, payload, security, session, discovery,
and interoperability behavior is specified by
32-mqtt-io.md. - The host runtime lowers MQTT points through the shared process image and starts the protocol-owned worker only after configuration and binding succeed.
- Broker connect, poll, publish, and reconnect work never runs on the PLC scan thread. Scan reads and writes use bounded snapshot and handoff state.
- Driver failures enter the common runtime I/O health and fault-policy path.
- MQTT is not part of the PLC executor, scheduler, or IEC language semantics.
MQTT runtime integration integrity¶
MQTT worker deadlines and fault projection are runtime integration contracts.
Broker delivery, MQTT sessions, topics, payloads, and reconnect semantics are
owned by 32-mqtt-io.md. Neither document turns these truST
product contracts into IEC language decisions or deviations.
- EtherCAT (backend v1)
- Driver name:
ethercat. - Deterministic process-image mapping for module-chain profiles (including
EK1100+ digital I/O modules such asEL1008/EL2008). - The default reviewed module-chain profile is
EK1100,EL1008, andEL2008, with deterministic input/output byte lengths and channel order. An explicit hardware adapter name is accepted as configuration authority; it is not proof that the adapter exists. - Startup discovery diagnostics emit discovered module summary and expected process-image sizes.
- Cycle-time health telemetry upgrades driver status to degraded when cycle read/write exceeds configured warning threshold.
- A read or write failure follows the configured fault/warn/ignore policy, except that a discovered process-image size mismatch is always faulted because continuing would reinterpret channel layout. Warn and ignore may keep the resource running only when the image shape remains valid, and must preserve the failure detail in degraded health.
- Non-mock adapters are backed by EtherCrab hardware transport on unix targets
and, with EtherCrab's
no_stdbuild, on chips with an Ethernet controller (specification 34, ARCH-11; owner, 16 October 2026). EtherCAT takes the Ethernet port it uses; such a board reaches the tools over its other links. - Deterministic
adapter = "mock"mode is available for CI/offline validation. -
Explicit v1 non-goals: no functional safety/SIL claims and no advanced motion profile support.
-
OPC UA client floating-point ingress
- A
Goodsample for a configuredFloat/REALorDouble/LREALinput point is accepted only when the value is finite. NaN, positive infinity, and negative infinity are rejected before the sample becomes an accepted cache value or is written to PLC variable storage.- A rejected sample marks the affected point faulted, exposes diagnostic detail, and leaves the PLC target unchanged. The runtime does not clamp, normalize, or substitute a default value.
- A previously accepted finite value may remain visible for diagnostics, but
a faulted point does not apply it as a fresh input. A later finite
Goodsample may recover through the normal subscription path. - This rule is limited to OPC UA client scalar ingress. Scalar OPC UA egress is governed by the worker/cache integrity contract below. Arrays, structures, subnormal values, signed zero, and other protocol/API/retained ingress boundaries require their own reviewed contracts.
OPC UA client worker, cache, and scalar-output integrity¶
This is a truST OPC UA product contract outside IEC 61131-3. IEC defines the
PLC data types exposed at the runtime boundary, but it does not define OPC UA
session generations, subscription event authority, write acknowledgements, or
worker shutdown. These rules therefore belong here, not in
IEC_DECISIONS.md or IEC_DEVIATIONS.md.
The client cache is closed over the configured bindings. A subscription sample from the active session may mutate cache or PLC input authority only when its variable name, node ID, declared OPC UA data type, and access mode exactly match one configured readable point. A current-session sample with an unknown or mismatched identity is a visible validation fault and must not create a dynamic point status, replace a configured point's value, or write PLC storage.
Every connection or recovery candidate has a monotonically increasing local session generation, and every callback sink created for that candidate carries that generation. Only events from the active generation may change connection, point, value, or freshness authority. A delayed sample, disconnect, or session-closed callback from an older generation is ignored and cannot regress a newer connected session. Within the active generation, accepted event time and point freshness never move backward; an older event cannot replace a newer value or connection/point state.
Each locally captured output intent carries a monotonically increasing write generation. Capturing a changed finite value atomically replaces the queued value for that point. A transport completion may remove the queued value or change its point quality only when the completion still names the exact generation that was transmitted. Completion of an older value cannot remove a newer queued value or overwrite its pending or error quality. A correctly correlated validation rejection terminally removes that exact generation, faults the affected point visibly, and does not reconnect the otherwise healthy session.
The worker sends the captured outputs while its session is up, whether the connection is connected or stale. Staleness describes the read values, not the session: a server whose values do not change sends no samples, and the program's outputs still reach it. A write that fails at the transport reconnects the session, as any transport failure does.
A subscription reports a value only when it changes, so silence alone is no
evidence that a value is stale (specification 23, "Projection Rules"). The
subscription publishes at the connection's poll_interval_ms and also monitors
the server's Server_ServerStatus_CurrentTime (ns=0;i=2258), sampled at that
interval, as its heartbeat: the server keeps the node current, so it reports in
every publish while the subscription lives. The heartbeat is no point and
reaches no global. A connection is stale when no notification, of a point or
of the heartbeat, arrived for its timeout and for at least three publishing
intervals; its readable points that were live become stale, and the input
phase does not apply their values. A point whose server reported a status that
is not good, a faulted point and a point without a first value keep their
states. The next live notification makes the connection live again, and with
it the points that silence made stale, with their last values. A point's
notification that is not live changes only that point, which the connection
reports among its degraded points; the connection stays live.
For scalar Float/REAL and Double/LREAL outputs, the value must be finite
at the declared width before it enters the pending-write cache. A non-finite
output cancels any older queued intent for that point, advances the local write
generation, reports an explicit point fault, and is never sent to the
transport. The queued-value baseline is cleared so a later finite value can be
queued normally. The runtime does not clamp, normalize, or substitute a
default.
At the OPC UA wire adapter boundary, a write response is successful only when
it contains exactly one status for every request, in request order, and every
status is Good. A missing, extra, or non-good result cannot be
reported to the worker as a successful batch. This is a fail-closed truST
adapter rule; it does not claim that IEC 61131-3 defines OPC UA service-result
semantics.
Worker shutdown or drop must wake an idle interval wait promptly rather than sleeping for the configured tick interval, attempt transport disconnect, make the shared connection and readable-point authority non-good, and join the worker thread. Raw values may remain available for diagnostics. This local wakeup guarantee does not claim cancellation of a transport call already in flight.
The wire transport's disconnect, at shutdown and before a reconnect, first stops the session's run loop and waits for it, because the loop reconnects a session that it finds disconnected; then it closes the session. A session that the loop reopened while it was being closed is never left to be dropped half open.
After a loss, the worker closes the lost session and connects a new one, with its own run loop and subscription, whose initial values and heartbeat make the connection live again. It never reopens the lost session in place.
Whether pending outputs replay after reconnect and how server source timestamps are ordered remain unresolved product decisions. This contract does not provide authority for tests that select those behaviors.
OPC UA client configuration, trust, and one-shot operation integrity¶
This is a truST OPC UA product and security contract outside IEC 61131-3.
Configuration, certificate stores, endpoint discovery, browsing, and OPC UA
service-response vectors are not IEC language semantics and therefore do not
belong in IEC_DECISIONS.md or IEC_DEVIATIONS.md.
An OPC UA client configuration contains at least one connection. Connection
names are trimmed, non-empty, and unique across the complete configuration.
Every connection has a trimmed non-empty opc.tcp:// endpoint with a
non-empty authority and at least one point. Point variable names are unique
across all connections; variable and node identifiers are trimmed and
non-empty. Node-ID grammar remains a wire-boundary validation because the
parser must remain available when OPC UA wire support is not compiled.
The parser defaults omitted client security policy and mode to none/none.
Policy and mode spelling is trimmed and case-insensitive, with - and _
ignored while matching. It accepts only none/none, basic256sha256 with
sign or sign_and_encrypt, and aes128sha256rsaoaep with sign or
sign_and_encrypt. Authentication defaults to anonymous. Anonymous
configuration rejects the presence of either username or password, including
blank values. Username authentication requires both trimmed non-empty fields.
Errors must not echo password contents.
Client polling is at least 10 ms. Timeout is in the closed interval from 1
through 60,000 ms. Every point declares one supported scalar type. The accepted
type spellings are bool/boolean, int/int16, dint/int32,
lint/int64, uint/uint16, udint/uint32, ulint/uint64,
real/float/float32, lreal/double/float64, and string,
matched after trimming and ASCII case folding. access and the legacy
writable alias are mutually exclusive rather than order-dependent; omission
means read access. Access accepts read, write, and
read_write/readwrite/read-write, with the same trimming and case-folding
rule. Read and read/write points are supported, while write-only points are
rejected because the runtime requires read evidence before reporting green
status.
Certificate listing, clearing, and explicit rejected-certificate promotion are
closed over the configured client PKI directory. They consider only regular
.der, .pem, or .crt files and return deterministic path order. A
symbolic-link listing/source root or entry observed during inspection is
ignored. A symbolic-link trusted promotion root or per-certificate destination
observed before copy is rejected, and that rejected source remains in place.
Clear removes only the listed trusted files. Explicit trust copies only listed
rejected files into the trusted root and removes only those exact rejected
files after each successful copy. Concurrent filesystem mutation between
inspection and file operation, batch atomicity across multiple promoted
certificates, and the destination policy for two different rejected paths with
the same filename remain unresolved and are not test authority.
For one-shot point reads and writes, the service response must contain exactly
one result for every request before any result is reported as successful. A
short or extra read vector is rejected before returning a value prefix. A
short or extra write-status vector is rejected before reporting batch success,
and every position in an exact-length vector must be Good. Result order is
interpreted as request order because the service response carries no separate
point identity at that boundary.
Client error classification is stable and ordered. Certificate failures map
to cert_untrusted; browse access, readability, and unknown-node failures map
to browse_denied; identity, credential, and security-policy rejection during
activation map to auth_required; absence of a matching supported endpoint
maps to unsupported_security_profile; and remaining transport failures map
to endpoint_unreachable. Classification changes recovery guidance only and
must not expose credentials.
- ADS floating-point ingress
- ADS
REAL/LREALvalues entering through client reads/notifications or ADS server writes are accepted only when every scalar value is finite. The same rule applies to eachREAL/LREALarray element. NaN, positive infinity, and negative infinity are rejected by the typed ADS decoder before a client sample is accepted, a server write is queued, or PLC variable storage is modified.- A rejected client sample reports point-data error quality and leaves the previous PLC target unchanged; it does not require the ADS session itself to disconnect. A rejected server write returns invalid-data status and queues no write.
- ADS client egress applies the same finite-value requirement before a value enters the shared write queue or reaches the wire. A rejected scalar or array output remains unchanged in PLC storage, cancels any older local pending intent for that point, advances its local write generation, reports explicit error quality, and clears the queued-value baseline so a later finite value can be queued normally. An ADS operation already in flight cannot be cancelled, but its completion cannot overwrite the newer rejection state.
- The runtime does not clamp, normalize, or substitute a default value. Subnormal values and signed zero are outside this rule.
Connector status and discovery truth contract¶
Connector reporting is an additive supervisory contract over protocol-owned
transport loops. It does not replace IoDriver, ADS, OPC UA, Modbus, MQTT, or
EtherCAT execution. Every connector report uses the following closed
vocabularies:
- lifecycle state:
disabled,configured,starting,ready,degraded,reconnecting,stale,not_ready, orfaulted; - health:
ok,degraded,faulted, orunknown; - discovery confidence:
confirmed,likely,port_reachable, orunavailable; - point quality:
good,stale,bad,unsupported,unavailable,write_pending, orwrite_failed.
ready with ok health requires current protocol or driver evidence.
Configuration, a starting worker, a listener without a runtime snapshot, and
transport reachability do not satisfy that requirement. They remain
configured, starting, not_ready, or unknown as applicable. A connector
is stale when the whole connection is no longer fresh. A single stale point
uses point quality stale; the connector may remain usable but must report
degraded point counts instead of collapsing the point and connector states.
ADS reports without a usable runtime snapshot are not_ready/unknown.
Connected ADS or OPC UA reports are ready/ok only with no degraded points;
otherwise they are degraded/degraded. Reconnecting, stale, disabled, and
faulted source states retain their distinct normalized states.
Discovery confidence describes evidence, not desirability or deployment readiness:
confirmedrequires a valid protocol-level exchange. Modbus uses FC43/14 device identification or an explicitly configured FC03 safe read. A normal or protocol-valid Modbus exception response confirms the protocol. MQTT classification is defined by32-mqtt-io.md.likelyrequires a protocol-shaped response that does not establish an accepted session. MQTT rejection classification and authentication evidence are defined by32-mqtt-io.md.port_reachableproves only that the TCP connection succeeded. It must not be rendered or consumed as a confirmed Modbus device or MQTT broker.unavailablemeans that no useful discovery evidence was obtained.
Consumers of this contract must reject unknown or missing state, health, and discovery-confidence values instead of rendering them as healthy. When one fleet peer supplies an invalid connector report, the peer's raw topology remains visible and the validation failure is shown to the operator; the bad report must not make that peer or the other configured peers silently vanish.
MQTT discovery evidence and session-cleanup behavior are specified by
32-mqtt-io.md.
The Modbus discovery wire probe emits a valid MBAP header with protocol ID zero, exact transaction ID, unit ID, and PDU length. FC43/14 uses transaction 1; an optional FC03 safe read uses transaction 2 after a fresh bounded connection. Request PDUs are limited to the Modbus TCP PDU maximum and an empty or oversized PDU is rejected before writing. A response is protocol evidence only when transaction ID, protocol ID, length, unit ID, function, and complete body all match the request. Response length is 2 through 260 bytes; a normal function response is accepted, and an exception function requires an explicit exception code. A mismatched unit, truncated exception, malformed length, unexpected function, or partial body is not Modbus evidence.
MQTT packet validation and CONNACK classification are owned by
32-mqtt-io.md; this shared discovery surface consumes only
the resulting evidence class.
Communication discovery scope and symbol browsing¶
Communication discovery is a bounded authoring operation. A directed Modbus or
MQTT TCP connection reports only port_reachable until the protocol probe
rules above earn stronger evidence. A CIDR scan broader than /24 is rejected
before enumeration. EtherCAT discovery is available only from the runtime host;
another origin receives an empty candidate set and an explicit runtime-origin
warning. Runtime-origin mock discovery may project the configured/discovered
module chain, but it remains mock evidence. Generic OPC UA discovery returns
the servers that the local discovery servers (FindServers), mDNS
_opcua-tcp._tcp.local. and GetEndpoints find, and lists every endpoint each
server announces, including the endpoints truST cannot use. A registered
protocol whose discovery is deferred returns an empty candidate set and a
stable unavailable warning; an unknown protocol is a request error.
comm.discover schema version 1 requires a non-empty string protocol. Its
optional scope is an object, origin is this_host or runtime, and passive
defaults true. Unknown request or scope keys are rejected so misspelled safety
limits cannot silently disappear. A requested non-passive scan still performs
connect/read-only operations and returns the stable warning that active write
probes are unsupported. Timeout defaults to 150 ms and is clamped to the
closed 1 through 2,000 ms range; malformed timeout types are request errors.
For truST runtime discovery (discovery), the timeout is the mDNS browse
window instead: it defaults to 2,000 ms and is clamped to the same range. The
browse collects every resolved service until the window ends. An interval in
which nothing arrives does not end it, because a responder may delay a shared
answer by 20 to 120 ms (RFC 6762 §6) and the querier repeats its first query
after one second (RFC 6762 §5.2); only the mDNS service stopping the browse
ends it before the window does.
ADS discovery takes an ordered list of ADS ports in scope.ads_ports for
comm.discover and in the ads_ports member of a search request. Without it
the ports are 301, 501, 851, 852, 853, and 854; duplicates are merged and the
list is sorted in ascending order. A port that is not an integer from 1 through
65,535 is a request error that names the value; the list never drops a value
silently. Every ADS find is probed on those ports with the symbol browse that
comm.browse_symbols runs, and the ports of one device are probed one after
the other, because they share the device's route; different devices are probed
at the same time. A port answers when its symbol tree is not empty, when its
route status is ok, or when its error code is symbol_upload_unsupported or
empty_symbol_table; any other error, or no answer, does not answer. An ADS
result carries the answering ports in ascending order as responding_ads_ports
and their browse results as ads_port_browse_results, each entry naming
port, tree, routeMissing, and, when present, routePlan and error. Its
ams_port is its own port when that port answered, otherwise the first
answering port, otherwise its own port, otherwise 851. A find without an
answering port is kept with a warning that no ADS port answered, instead of
being hidden. Probing starts no new port once the search deadline has passed.
An identified ADS target keeps its OS version and its fingerprint and the port
that answered, not always 851. The ADS identify round is bounded by the
requested timeout, and in a search by the search deadline.
Protocol aliases are deterministic: Modbus hyphen/underscore forms select
modbus_tcp; OPC UA server and client aliases remain distinct; MQTT broker
selects mqtt; ADS client and TwinCAT select ads; and truST/runtime/mDNS
aliases select discovery. Deferred registered protocols return their
protocol-specific stable title in the unavailable warning. A blank or unknown
protocol never falls through to a generic registered protocol.
A Modbus directed host accepts a host, IPv4 address, or bracketed IPv6 address
with optional nonzero port and defaults to port 502. Its unit ID defaults to 1.
The optional safe FC03 read requires probe_read_address; quantity defaults to
1 and is limited to 1 through 125. Supplying quantity without an address, zero,
or a value above 125 rejects the request before a connection. A CIDR is trimmed
and parsed as IPv4/prefix; /24 is the broadest accepted range. Enumeration is
ascending and canonicalized to the containing network: /24 through /30
exclude network and broadcast, /31 includes both point-to-point addresses,
and /32 includes its sole address.
MQTT target validation, directed-only scope, deterministic endpoint order, and
TLS evidence limits are owned by 32-mqtt-io.md.
OPC UA client discovery requires a directed host or endpoint. A bare host
defaults to opc.tcp://<host>:4840; an explicit valid port, bracketed IPv6
authority, or path is retained, and a canonical opc.tcp:// endpoint is
trimmed and retained. Other schemes, userinfo, zero or invalid ports, query or
fragment components, and ambiguous unbracketed IPv6 reject before calling Get
Endpoints. Endpoint discovery failure is an explicit warning and empty result,
not a successful fabricated endpoint.
Every candidate ID is deterministic and contains only ASCII alphanumeric, hyphen, underscore, period, and the protocol-owned separators added by the projection. Candidate source and confidence come from the actual observation; labels and parameters do not upgrade TCP reachability into protocol confirmation. Candidate order follows deterministic target/source order, and duplicate observations are collapsed by their protocol identity.
Symbol browsing returns a versioned tree whose protocol, kind, IDs, paths,
type labels, writable flag, and protocol-specific identifiers are derived from
the owning source:
- Local project browsing returns declared globals in deterministic name order and does not claim runtime values.
- EtherCAT channel browsing returns the configured module and channel order with exact process-image direction, byte/bit coordinates, and data type.
- Cached ADS browsing canonicalizes the supplied snapshot and returns the same
deterministic candidate groups and import shape owned by the ADS
symbol-import contract. The target AMS identity accepts the documented
ams_net_idandtarget_net_idaliases. - A live ADS upload failure classified as a missing return route returns a
structured
route_missingerror plus the credential-free route plan. It must not fabricate a symbol tree or successful import. - Any other live ADS browse failure of a valid target answers an empty tree
with the classified protocol error (
ads_port_unavailable,symbol_upload_unsupported,empty_symbol_tableorsymbol_upload_failed), never a request error. This includes a failure before the device's port answers, when this computer's ADS identity toward the target cannot be derived, and a build without theads-wirefeature, whose browse failed upload issymbol_upload_failed. A request whose target does not decode stays a request error. - OPC UA leaf browsing retains the raw node ID and maps a supported scalar data type into the exact apply-facing type name. Browse failure retains the classified protocol error instead of returning an empty successful tree.
- OPC UA browsing follows every
BrowseNextcontinuation point until the server has no more references for a node, within the configured depth and node bounds. A depth or node bound that limits the tree adds a warning naming that bound; the tree is never silently truncated.
comm.browse_symbols schema version 1 requires a non-empty string protocol.
Kind defaults to symbols; protocol and kind are trimmed, ASCII-case folded,
and hyphen-normalized before dispatch. Unknown request or target keys reject.
The supported protocol/kind pairs are ADS symbols, OPC UA client nodes,
EtherCAT channels, and local OPC UA server, ADS server, or OpenOT symbols.
Every other pair is an explicit request error. Local browsing is selected only
for symbols on a local-capable protocol when the target is absent, explicitly
local, or has no remote host; a nonempty remote host never falls through to
project compilation.
Every successful response includes exact schema version, canonical protocol,
canonical kind, and a tree. Empty optional error, route, ads_import, and
warning members are omitted. A structured protocol error contains an empty
tree and no route/import payload. Secrets from targets, cached snapshots, route
plans, and protocol errors never appear in the tree, warnings, or diagnostic
text.
A cached ADS snapshot must use the current snapshot schema, a non-empty trimmed
route name, and valid non-empty dotted symbol paths. Empty segments, leading or
trailing periods, duplicate descriptor identities, and conflicting duplicate
symbol paths reject before tree or import projection. Snapshot order is
canonicalized before both projections. ADS groups and leaves are sorted
lexicographically at every level. Groups carry deterministic path-derived IDs,
type = group, children, and no size or writable claim. Leaves retain exact
remote path, ADS source type, byte size, and a writable flag derived only from
the remote Write capability. The tree and ads_import.snapshot contain the
same descriptor denominator.
An ADS live target accepts ip/host and ams_net_id/target_net_id
aliases. Host and AMS Net ID are trimmed; host is a bare IPv4, IPv6, or DNS
name without scheme, userinfo, path, query, or fragment. AMS Net ID is exactly
six decimal octets in the range 0 through 255. AMS port defaults to 851 and
must be nonzero. Conflicting duplicate aliases reject. Missing target and
snapshot, or an instance ID without the target needed for live browsing,
reject before route or wire activity. Cached browsing performs no route check
or network activity.
OPC UA browsing target, security, authentication, and credential rules are the same as OPC UA client Test. The derived security profile's anonymous flag must agree with the normalized auth mode. Each projected node preserves child order, raw NodeId, browse path, OPC UA type NodeId label, apply-facing scalar data type, and writable flag recursively; only its UI ID is sanitized.
EtherCAT browsing selects a configured EtherCAT driver by its exact
protocol-qualified instance identity, retaining the original io.drivers
index when other protocols precede it. Without an instance it selects the
first configured EtherCAT driver. A stale or cross-protocol identity rejects.
Module and channel order is configuration order; module IDs, paths, model,
slot, channel count, per-channel index, one-byte BOOL type, and ownership are
projected without claiming a live hardware observation.
Reading a project file, generated temporary artifact, cached snapshot, or live wire response remains an external oracle. A structural response-shape assertion may map to this contract, but an unresolved file or wire observation cannot be promoted to proof merely because surrounding assertions are static.
One search, device identity, and presence¶
The owner asked on 16 October 2026 that truST find everything it can talk to,
and find it better: "we need to support everything and make it better if its
possible". These rules extend comm.discover; each protocol's rules above
still apply to its part of a search. Searching is runtime logic, written in
Rust; VS Code and other tools only start a search and show its results.
Search. comm.search.start begins one search that runs every requested
source at the same time and returns a search ID. comm.search.status returns
each source's progress and every result found so far, so that a tool shows
results while the search runs; comm.search.cancel ends the search. A search
ends when every source has finished or when its deadline passes: 10 seconds by
default, at most 60 seconds. A source still running at the deadline reports
timed_out together with the results it already had. A search runs on the
tool's own computer (origin = this_host) or inside a runtime
(origin = runtime), which reaches the networks and buses of that runtime's
computer. The command line runs the same search as trust-runtime discover,
printing results as they are found (--json prints one JSON object per line),
and --only <source>,… limits it to named sources. With --project <dir> the
command reads <dir>/.trust-runtime/devices.toml, the choices that the Discover
pane remembers (specification 25 §2): without --only it runs the sources named in
the remembered protocols, or every source when the file remembers none, and it
leaves out the devices that the project ignores (ignored) unless
--show-ignored is given. A remembered name that is not one of the ten sources is
left out, because the command line runs no protocol that a runtime advertises; when
no remembered name is left, the command refuses the search and asks for --only.
A listening source (mDNS, Bluetooth, an ADS broadcast) listens until the search
ends, at its deadline or on cancel, and reports each result as it arrives. The
150 ms window above applies only to a single comm.discover request.
comm.search.status, comm.search.cancel, and a comm.search.start without a
confirmed fieldbus scan need the Viewer role; a comm.search.start with
confirm_fieldbus_scan = true needs Engineer (§6.9.5).
Requests. comm.search.start takes an object whose members are all
optional; an unknown member, at any level, is a request error, as for
comm.discover:
origin: where the search runs. A search started over a runtime's control connection runs inside that runtime, so its origin isruntime; another value is a request error. A tool that runs the search itself usesthis_host.only: the IDs of the sources to run (the table below). Without it every source runs. An empty list or an unknown ID is a request error.targets:hosts, a list of at most 256 known addresses, each a host name or IP address with an optional port and nothing else; andcidr, one IPv4 subnet of at most/24, read by the CIDR rules ofcomm.discover.deadline_ms: from 1 to 60,000, and 10,000 when absent; another value is a request error.confirm_fieldbus_scan:trueruns the fieldbus sources; absent orfalseskips them.fieldbus: the buses to scan, each{"bus": "ethercat"}with an optional"adapter": "<name>". A bus named without an adapter, and a confirmed scan without entries, use the adapter of the project's EtherCAT driver, ascomm.discoverdoes.modbus:unit_ids, the Modbus TCP unit IDs to probe, each from 1 to 247 and without repeats; without it, unit ID 1. A unit ID outside 1–247 is a request error.
It answers {"search_id": "s-<n>"}. comm.search.status takes search_id and
an optional since cursor. It answers schema version 1 with search_id,
origin, state (running, done or cancelled), deadline_ms,
elapsed_ms, cursor, sources, devices, and warnings. Each source
reports its id and state: searching, found, timed_out, cancelled or
skipped. A source that ran also reports count, the results it has, and the
warnings of what it could not search; a skipped source reports reason
instead. A device reports its id, addresses, ams_net_ids, usb_serials,
runtime_ids, last_seen_ms, and finds; each find reports its protocol
(a source ID), source, confidence, simulated, identity (its kind names
the protocol), the params that comm.discover reports for a connection to it,
its warnings, and last_seen_ms. Empty ams_net_ids, usb_serials and
runtime_ids lists, and empty source and find warnings, are left out.
Without since every device is listed. With
since only the devices that changed after that cursor are listed, and
merged lists {"id", "into"} for each device that joined another after it, so
a tool that polls with the last cursor keeps a complete list.
comm.search.cancel takes search_id and answers {"search_id", "state"}:
cancelled, or done when the search had already ended. A runtime keeps its
last eight searches, each for five minutes after it ends. A ninth search
replaces the oldest search that has ended; while eight searches are still
running, comm.search.start answers the error code search_busy. A search ID
that the runtime does not keep answers search_not_found.
Sources. A search uses every source that its origin can run:
| Source (ID) | Finds | How |
|---|---|---|
truST runtimes (trust_runtime) |
Runtimes on the local network and in the mesh | mDNS _trust._plc._tcp.local. (§6.12), queries to mesh peers, runtime cloud presence |
ADS (ads) |
TwinCAT and ADS devices, their responding ADS ports, and their routes | UDP identify, unicast and broadcast; a probe of the configured ADS ports; the target's route list |
Modbus TCP (modbus_tcp) |
Modbus servers and their unit IDs | A directed host or a subnet of at most /24, probed in parallel; FC43/14 identification; unit ID 1, or a requested range within 1–247 |
MQTT (mqtt) |
Brokers | mDNS _mqtt._tcp.local. and directed hosts; a TLS handshake on port 8883 |
OPC UA (opcua) |
Servers and their endpoints | Local discovery servers (FindServers), mDNS _opcua-tcp._tcp.local., and GetEndpoints for every server found |
EtherCAT (ethercat) |
SubDevices on a bus adapter of the runtime's computer | A bus scan that reads each SubDevice's identity in PRE-OP with the process data mapped, without bringing the bus to SAFE-OP or OP; without a named adapter it scans the computer's up, non-loopback network adapters |
GPIO (gpio) |
Chips and lines of the runtime's computer | The Linux GPIO character device |
Serial (serial) |
Serial ports, and on request Modbus RTU devices on one port | Port listing; a Modbus RTU scan over requested unit IDs, baud rates, and parity |
USB (usb) |
Supported chips with or without truST, debug probes, and USB-serial adapters | The USB device list; nothing is written or reset |
Bluetooth (bluetooth) |
Bluetooth LE devices, where the origin computer has Bluetooth | A passive advertisement scan |
EtherCAT and GPIO run only at origin runtime; USB and serial run at either
origin and list the devices of the computer where the search runs. A source
that does not run reports skipped with its reason in words, for example that
its origin cannot
run it, that its fieldbus scan was not confirmed, that the search names nothing
it can probe, or that this build of truST does not include it. At origin
runtime the truST runtimes source reads the runtime's own mDNS discovery
(§6.12), so it is skipped while [runtime.discovery] enabled = false.
A protocol that a runtime advertises beyond the ten sources (its comm.schema
lists discover for it) is not a source of comm.search.start, which names only
the sources of the table above and refuses any other name. The panel that shows the
search runs such a protocol through that runtime's comm.discover for it, once for
each target, and merges its finds into the same search by the rule of
"Devices" below (specification 25 §2). A runtime's own search never runs it.
Inside a runtime, the truST runtimes source also queries the mesh peers (O31:
the search may scout for mesh peers). The peers are those that the runtime's
mesh session has seen join; the search starts no scouting of its own. Each peer
is asked for its identity with a read-only query of its identity queryable, each
second until the search ends and each within the search's deadline. The answer
gives the runtime's runtime ID (the one its announcement and status give) as
stable_runtime_id, beside the mesh name that keys its identity queryable. An
answer without it is named by its mesh name. A runtime that sends its name,
version and platform gives them as optional fields; a runtime that sends none is
named by its mesh name alone. Each answer is a truST runtime find from the source
mesh, merged by runtime ID with its mDNS find. A mesh find has no control
endpoint and no network address, so it carries the warning "Runtime found
through the mesh without a control endpoint; adopt may require manual endpoint
entry." A peer that does not answer by the deadline is left out, with no error.
An answer that is not the identity of the peer that was asked is left out, with
a warning that names the peer. On the tool's own computer there is no mesh
session, so this part does not run.
Runtime cloud presence. Inside a runtime, the truST runtimes source reports each
discovered runtime with the runtime cloud's presence, by the rule of the runtime
cloud state (the presence projection in runtime_cloud/projection.rs). A
runtime has a mesh endpoint when its announcement gives a mesh port and an
address, and a web endpoint when it gives a web port and an address. It is
stale when it has no mesh endpoint, or when it was last seen 30 seconds or more
ago. It is partitioned when it has no mesh endpoint and was last seen 120
seconds or more ago. When its announcement is at least 30 seconds old, or dated
after now, and it has a web endpoint, the web endpoint is connected to: a
connection accepted within 200 milliseconds means the runtime was seen now. The
find's last_seen is that observation time. A stale runtime carries the warning
stale, and a partitioned runtime carries the warnings stale and
partitioned. Presence is read at most once a second for each runtime. The
tool's own computer has no runtime cloud, so its finds carry no presence.
The route list of an ADS target is read by the ADS source of a search (O20). The
method is the one that the ads crate's adstool route list runs (ads 0.4.4,
examples/adstool.rs lines 378–404; the Beckhoff sources checked on 18 October
2026 documented no route read). The search connects to the target's system
service on AMS port 10000 and reads index group 803 (0x323, ROUTE_LIST) at
index offsets 0, 1, 2 and so on, until ADS error 0x716 ends the list. Each entry
starts with a 44-byte header. Bytes 0–5 are the AMS Net ID. Bytes 8–11 are the
flags, a little-endian u32 whose bits are 0x01 temporary, 0x80 unidirectional,
and 0x100 virtual or NAT. Bytes 32–35 are the host length and bytes 36–39 the
name length, each counting its NUL. The host text starts at byte 44 and ends
with its NUL; the name text follows it and ends with its NUL. Both texts are
Windows-1252, the Windows ANSI code page, so each byte of a text is one
character; a byte that is not valid UTF-8, such as 0xFC for ü, is not damage.
The timeout and the largest fragment (bytes 12–19) are not reported. An entry is
damaged only when it is shorter than 44 bytes, a length is 0, the two texts need
more bytes than the entry holds, or a text lacks its NUL at its end or holds one
before it. The search reports a damaged entry with its index and reads on.
The find of a target carries route_list in its params. With status ok, it
gives routes (each with name, ams_net_id, host, temporary,
unidirectional and virtual_nat) and damaged (each with index and
reason). With status unavailable, it gives reason and, when the target
answered with an error, that error. A list that ends with 0x716 at its first
entry is empty and has status ok. A read refused with an ADS error, and a read
that fails after some entries, make the list unavailable. So does a search
deadline that passes before the list ends. The search opens no connection and
starts no read after its deadline, and it never reports a partial or an empty
list in these cases.
The read needs a route from the target to this runtime. When the target gives no
answer to the first read, the list is unavailable with a reason that says there
is no route from the target to this runtime. The search adds no route: its
Safety rule says that a search only reads and never changes a device's state.
comm.discover reads no route lists. The read is unproven (O20): it stays
unproven until a real TwinCAT computer confirms it.
A known address is probed at each protocol's own port. An explicit port is used
by Modbus TCP, MQTT and OPC UA; ADS identifies the host alone. Without a known
address or subnet, Modbus TCP probes the /24 of each private IPv4 address
(RFC 1918) of the origin computer's network interfaces that
discovery.interfaces allows, or of every interface when that list is empty or
the search runs on the tool's computer. comm.discover keeps its rule of the
first such interface.
MQTT brokers and OPC UA servers are also found through what they announce. A
search browses _mqtt._tcp.local. and _opcua-tcp._tcp.local. until it ends,
and reports an announced service only when the protocol's own probe answers it:
an MQTT broker at its announced address and port, and an OPC UA server asked
with GetEndpoints at opc.tcp://<address>:<port><path>, where <path> is the
service's path TXT entry and / when it names none. Such a find's source is
mdns; a service that does not answer is a warning that names it, not a device.
An OPC UA search also asks a local discovery server for FindServers: this
computer's own at opc.tcp://localhost:4840, and that of each known address at
port 4840 unless the address names one, and then asks every discovery URL of
every server that answers with GetEndpoints. Such a find's source is lds. A
discovery server that does not answer is passed over in silence for this
computer's own and is a warning for a known address.
GPIO. The GPIO source lists the lines of every GPIO chip of the runtime's
computer through the Linux GPIO character devices (/dev/gpiochip*). Each line
is reported by its chip, the chip's label, its offset within that chip, its
name, its consumer, whether it is in use, and its direction. On a kernel
without GPIO character devices it falls back to the sysfs interface
(/sys/bus/gpio/devices, then /sys/class/gpio), which reports the chip, the
chip's label, the line and its direction where the kernel gives them, and every
line from that fallback carries a warning that says so.
USB and serial. The USB source lists the USB devices from the descriptors that the operating system already holds: sysfs on Linux, IOKit on macOS, and on Windows the device manager and the hub's cached connection information. It never opens, claims, resets or writes a device, and it reads only the strings that the operating system keeps, never one that it would have to ask the device for (owner, 15 October 2026; specification 34 TARGET-04). It reports the devices that it recognizes by their vendor and product ID, and every other device that has a serial port; a phone, a keyboard or a storage device without one is not reported:
- a supported chip's boot loader, named as such:
0483:DF11is theSTM32 boot loader (DFU), which every STM32 with a USB DFU boot loader shows. The chip has no truST yet; - a supported chip's USB controller, named as such:
303A:1001is theEspressif USB-Serial/JTAGcontroller of the ESP32-C6. It shows the same descriptors with or without truST, in its ROM download mode, and on other Espressif chips, so its result says that neither the chip nor truST is confirmed; connecting confirms them; - a debug probe: the ST-LINK/V2, ST-LINK/V2-1 and STLINK-V3;
- a USB-serial adapter: FTDI, Silicon Labs CP210x, WCH CH34x and Prolific PL2303;
- a truST runtime: truST's own ID
1209:7472(requested from pid.codes), and during development the pid.codes test ID1209:0001when its maker istruSTor its producttruST runtime. Its version is the major and minor version that itsbcdDevicecarries, and its control endpoint isusb://followed by its serial number; - any other device that has a serial port, as a USB-serial device.
A result names no chip for a boot loader or a USB controller. The supported
chips that show its IDs are those whose chip files name the IDs as their
usb_bootloader (specification 34, Appendix H.2); several chip files may name
one ID, and the tools list them all from the chip files. The chip is confirmed
when connecting and before any installation.
The serial ports behind a USB device are the ports whose USB device has its
vendor ID, product ID and serial number; for a device without a serial number,
the ports with its vendor and product ID, when no other listed device without a
serial number has the same pair. The serial source lists every serial port that
the operating system reports, with the USB device behind it when there is one,
and never opens a port. A port whose UART the operating system reports as
absent (type 0 on Linux) is left out. Where the operating system offers no
port listing, as on a Linux computer without /sys/class/tty, the serial
source is skipped with that reason. Both sources list once and finish. A
serial port's params are {"port": "<name>"}, and those of a USB device that
runs or may run truST are {"control_endpoint": "usb://<serial number>"}.
Safety. A search only reads. It never writes a device value, never changes
a device's state, and never resets a board. EtherCAT identification maps the
process data in PRE-OP and never brings the bus to SAFE-OP or OP, so no output
changes, and it refuses an adapter that a running EtherCAT driver of the
runtime owns: nothing on that adapter is scanned, and the driver's own live view
of that bus is reported instead. That refusal is complete: when the adapter of a
running EtherCAT driver cannot be determined, no EtherCAT adapter is scanned and
a warning says why. A
Modbus RTU scan refuses a serial port that a configured driver owns. A fieldbus
scan (EtherCAT, Modbus RTU) runs only when the request sets
confirm_fieldbus_scan = true; tools ask the user first.
Devices. Results that belong to the same device are reported as one device
listing every protocol found on it. Results belong to the same device when they
share a network address, an AMS Net ID, a USB serial number, or a truST runtime
ID. A result with none of these forms its own device: an EtherCAT SubDevice by
its adapter and position, the lines of one GPIO chip together by their chip, a
serial port by its name, and a USB device without a serial number or a serial
port by its bus and port. A USB device and the serial ports behind it are one
device. When a result links two devices, they become one, and the device found
first keeps its ID. Each device carries its addresses, every protocol result
with its source and confidence, its identity, and its last_seen time.
Identity. Each protocol reports the identity it can read:
- truST runtime: name, ID, version, platform (operating system and architecture, or the chip), whether control needs a token, and its control and web addresses;
- Modbus: vendor name, product code, and revision from the FC43/14 basic objects, when the device answers them;
- ADS: computer name, AMS Net ID, TwinCAT version, operating system version, and responding ADS ports;
- OPC UA: application name and URI, product URI, every endpoint with its security policy and mode, including endpoints truST cannot use (marked unsupported), and the SHA-256 fingerprint of the server certificate;
- EtherCAT: for each SubDevice its position, alias address, vendor ID, product code, revision, and serial number, its name, and the bus's mapped input and output byte counts, and the device of the project's ESI files that matches it, when one does;
- MQTT: the protocol version accepted, and whether login and TLS are needed;
- GPIO: chip name and label, the line offset within its chip, the line name, its consumer, whether the line is in use, and its direction;
- USB: vendor and product ID, maker, product name, serial number, what the
device is (a supported chip's boot loader or USB controller, a debug probe, a
USB-serial adapter or device, or a truST runtime) and its name, such as
STM32 boot loader (DFU), the serial ports behind it, a truST runtime's version, and the control endpoint of a device that runs or may run truST. It names no chip: the chip files say which supported chips show the IDs of a boot loader or a USB controller; - serial: the port name, whether it is a USB, PCI, Bluetooth or other port, and, when there is one, the USB device behind it with its vendor and product ID, maker, product name, and serial number;
- Bluetooth: name, address, and signal strength.
Confidence. Every result uses the closed confidence vocabulary of this
section. A valid protocol exchange is confirmed: an ADS identify reply, an
OPC UA GetEndpoints answer, an EtherCAT identity read, an mDNS answer from a
truST runtime, or a USB descriptor. A GPIO line or a serial port that the
operating system lists is confirmed too. No other value, such as observed, is reported. A simulated
(mock) result carries simulated = true and is never presented as hardware.
Compare with the project. comm.compare compares a search with the devices
that the project's configuration names. It lists each configured device that the
search did not find (missing), each found device that the project does not use
(unused), and each found device whose identity differs from the configuration
(differs), for example an EtherCAT SubDevice in another position or a Modbus
device with another product code. The command line runs it as trust-runtime
discover --compare --project <dir>, and the Discover pane marks each device
card with the same comparison and, on request, lists it (specification 25 §2). The comparison is a
function of the search's devices and the project's devices alone: it reads no
file and opens no connection, and the same inputs give the same lists in the
same order.
- Request.
comm.comparetakes{"search_id": "s-3"}, or{}for the most recent search that the runtime keeps; another member is a request error. It needs the Viewer role (§6.9.5), because it only reads a search that Viewer may read and the files of the runtime's own project. It answers an error, and changes nothing, with the code: search_not_foundfor a search ID that the runtime does not keep, and for{}when it keeps none;search_not_finishedfor a search that is still running or was cancelled: a search that stopped before every source finished has not looked everywhere, so a device that it did not find is not a device that is missing;compare_no_projectfor a runtime that has no project folder;compare_project_unreadablefor a project whose files cannot be read or understood, with the file and the reason in the text.- The project's devices are read from the project folder:
io.toml, and, for ADS and OPC UA, the files thatruntime.tomlnames withconfig_pathwhen it enables them ([runtime.ads]and[runtime.opcua_client]). A driver withenabled = falseis not a device. Five protocols name a device:
| Protocol | Configured in | The device is named by | The project also states |
|---|---|---|---|
| Modbus TCP | each modbus-tcp driver |
address (IP address and port) and unit_id (1 when absent) |
the optional vendor_name, product_code and revision that it expects of the device |
| EtherCAT | each module of each ethercat driver |
the driver's adapter (mock when absent) and the module's model |
the module's slot, its position |
| MQTT | each mqtt driver |
broker: host and port |
nothing that a search reads |
| OPC UA | each connection of the OPC UA client file | endpoint_url: host and port (4840 when the URL names none) |
security_policy and security_mode |
| ADS | each connection of the ADS file | target_net_id, the AMS Net ID |
host and ams_port, which are not compared: a TwinCAT system answers from the address of the adapter that took the request, and a search asks only the ports it is given |
The vendor_name, product_code and revision of a modbus-tcp driver are
optional text in the driver's params in io.toml, the FC43/14 basic objects
as the search reports them. The driver ignores them: they only say what the
project expects of the device at that address, and a driver that states none,
or only blank text, cannot differ. comm.schema offers the three in its
modbus_tcp entry, as optional advanced string fields that are empty by
default and are labelled Expected vendor name, Expected product code and
Expected revision, so that the connection form shows what a connection
states and a Save of the form writes it back. comm.apply writes blank text as
no key and refuses a value that is not text with Enter text.. A module's
model and slot are those that the driver itself reads, so a driver without
modules has the driver's default chain. A driver written under an alias that
the driver registry accepts (modbus_tcp, mqtt-tcp, ether-cat, ecat)
names the devices of its protocol. The other drivers (gpio, simulated,
loopback) and the other sources (truST runtimes, GPIO, serial, USB,
Bluetooth) name no device that this comparison covers: their results are not
compared, carry no mark, and are in no list.
- Matching. A configured device and a result of the search are the same device
when:
- Modbus TCP: the result's address (the IP address and port it was probed at)
and unit ID equal the driver's;
- MQTT: the result's broker equals the driver's host and port;
- OPC UA: the device has the configured host among its addresses and one of its
OPC UA results has an endpoint URL with the configured port;
- ADS: the result's AMS Net ID equals target_net_id;
- EtherCAT: the SubDevice is on the same adapter (names compare ignoring case)
and the pairing below takes the module and the SubDevice together.
EtherCAT modules and SubDevices are paired one adapter at a time, in three
steps. A module and a SubDevice are used at most once, and the modules are taken
in the order of their positions. A model compares ignoring case. First, a module
and a SubDevice of the same model at the same position. Second, each module still
free takes the free SubDevice of the same model that is nearest in position (the
lower position when two are as near); the position differs. Third, each module
still free takes the free SubDevice at its position, whatever its model; the
model differs. A module left over is missing, and a SubDevice left over is
unused.
- Host names. A configured MQTT broker or OPC UA server that is named by a host
name matches a result found under that name. localhost matches the loopback
addresses. Any other name is not looked up: a search reports the addresses it
probed, and MQTT resolves a name before it probes. A configured device named by a
host name that no result carries is listed under not_compared with the reason
host_name, not under missing, and while one is listed, a result of that
protocol that matches nothing is neither unused nor marked, because it may be
that device.
- Differences. A matched device differs when:
- Modbus TCP: the driver states vendor_name, product_code or revision and
the device reports another value, or none, for it; text compares after
trimming, and exactly;
- EtherCAT: its position is not the module's slot (position), or its model
is not the module's (model);
- OPC UA: none of the server's endpoints on the configured port announces the
connection's security policy together with its security mode (security);
- MQTT and ADS: never; the project states nothing about them that a search reads
reliably.
- Not compared. A configured device that the comparison cannot judge is listed
under not_compared and in no other list. The reason is not_searched when the
search did not run the device's source (it was skipped, or the request did not
name it), so that a search from a computer, which never runs EtherCAT, does not
call every EtherCAT module missing; host_name as above; and unit_id for a
Modbus unit ID outside 1–247, which a search cannot probe. While a configured
device of a protocol is listed with host_name or unit_id, a result of that
protocol that matches nothing is neither unused nor marked.
- Ignored devices. A found device that the project ignores (<project>/
.trust-runtime/devices.toml, decision O14, specification 25 §2) is never listed
under unused. It still matches, so a configured device that an ignored result
answers is not missing, and a difference is still listed.
- Reply. Schema version 1: schema_version, search_id, and four lists. Each
entry holds the facts of the devices it names, an object whose members depend on
the protocol:
| Protocol (source ID) | Facts of configured |
Facts of found |
Fields that can differ |
|---|---|---|---|
modbus_tcp |
address, unit_id, and the vendor_name, product_code and revision that the driver states |
the same members, null for a stated one that the device did not report |
vendor_name, product_code, revision |
ethercat |
adapter, position, model |
the same members | position, model |
mqtt |
address |
never listed | none |
opcua |
address, security (<policy> / <mode>) |
address, security (the pairs of the endpoints on that port, joined by ,) |
security |
ads |
ams_net_id, address (the configured host) |
never listed | none |
missing:protocolandconfigured, for each configured device that was compared and that no result matched. In the order of the source table, then by the configured facts (address and unit, adapter and position, AMS Net ID).unused:device_idandprotocols(the compared protocols found on it, in the order of the source table), for each found device that has a compared result, none of whose compared results matched, and that the project does not ignore. By device ID.differs:device_id,protocol,configured,foundandfields(the names of the facts that differ, in the order of the table), for each matched device that differs. By device ID, then in the order of the source table.not_compared:protocol,reasonandconfigured. In the order ofmissing.
For example, a Modbus device at 10.0.0.9 that the search did not find, an unknown Modbus device at 10.0.0.12, and an EtherCAT EL2008 that stands at position 3 where the project has it at position 2:
{"schema_version": 1, "search_id": "s-3",
"missing": [{"protocol": "modbus_tcp", "configured": {"address": "10.0.0.9:502", "unit_id": 1}}],
"unused": [{"device_id": "dev:ip:10.0.0.12", "protocols": ["modbus_tcp"]}],
"differs": [{"device_id": "dev:ethercat:eth1:3", "protocol": "ethercat",
"configured": {"adapter": "eth1", "position": 2, "model": "EL2008"},
"found": {"adapter": "eth1", "position": 3, "model": "EL2008"},
"fields": ["position"]}],
"not_compared": []}
Identify. identify makes a runtime show where it is: for the time asked it
blinks the output named in runtime.toml [runtime.identify] output (an I/O
address or a GPIO line) and reports identifying in its status. The request,
its timer, the blink and the identifying member are one implementation that
every runtime runs (specification 34, ARCH-11 and SERVICE-07); a platform
supplies only the way to drive the one output. The program must not use that
output, so identify never changes an output that the program uses. It works in
every state of the resource, running, paused, stopped or faulted: a stopped box
is exactly when someone needs to find it.
- Request. The parameters are
{"duration_s": n}and nothing else, wherenis a whole number of seconds from 1 through 60. The result is{"identifying": true, "duration_s": n}. The operation needs the Operator role (§6.9.5). Missing parameters, another member, a value that is not a whole number (a string, a fraction, a negative number) and a value of 0 or of more than 60 are refused with the errorinvalid params: duration_s must be a whole number of seconds from 1 to 60, and change nothing. The state of the resource decides nothing: the request is accepted while the resource isrunning,paused,stoppedorfaulted, and also while it isready(it has not started its loop yet), where it blinks from the first pass of the loop. - Other refusals, checked after the parameters, in this order:
- Without
[runtime.identify] outputthe request answers the error codeidentify_not_configured, with the textidentify is not configured: set [runtime.identify] output in runtime.toml. A runtime on a chip answers it too, whatever its project sets, until the chip's firmware drives an output foridentify(specification 34, SERVICE-07). - An output that cannot be made ready (a GPIO line that another process holds,
or a computer without GPIO character devices) answers the error code
identify_output_unavailablewith the textidentify output unavailable: <reason>, and starts nothing. - The output.
[runtime.identify] outputis a string in one of two forms, and the spaces around the whole value do not matter. Another form, an empty string and an unknown key in the section are configuration errors, and a section withoutoutputmeans the runtime has no identify output. - An I/O address, written exactly
%QX<byte>.<bit>in decimal without leading zeros, for exampleoutput = "%QX0.7": one bit of the output process image. A wildcard, an input or marker address, a byte, word or larger address, a hierarchical address and a shorthand without the bit (%QX5) are refused. The runtime sets the bit in a copy of the output image that it hands to the I/O drivers, after the program's outputs are written; the program's own image, Live Values andio.listnever show it. An I/O driver ofio.tomlmust carry that bit to the real output, for example agpiooutput entry withaddress = "%QX0.7". - A GPIO line, written
gpio:<line>(gpio:in any letter case), for exampleoutput = "gpio:17": a line number in decimal, as thelineof agpiodriver is on a computer, so a pin name such asPD12is refused. It is a line of the chip that the project's first enabledgpiodriver names (itschipandbackend, §6.5), or of/dev/gpiochip0on the character-device backend when the project has none. The runtime requests the line as an output, low, whenidentifyfirst runs, and drives it directly, whether or notio.tomlmentions any GPIO. - The blink is 2 Hz with 50 % on: the output is on for 250 ms and then off for 250 ms, and so on. The runtime sets the output at every point where it can change an output:
- where it hands the outputs to its drivers at the end of each scan cycle;
- in every pass of the wait of a resource that scans nothing: a
paused,stoppedorfaultedresource. On a computer the stopped and the faulted wait poll every 10 ms, and the paused wait every cycle interval, or every 10 ms whileidentifyruns when the interval is longer; on a chip the pass of the runner's loop does it, whether or not a scan is due (specification 34, SERVICE-07).
The first of those points after the request turns the output on and starts the
count of 250 ms; each later point changes the output when a 250 ms edge has
passed, and at most once. With a cycle interval of 250 ms or less every edge
therefore falls less than one cycle interval after its 250 ms mark, with a
longer interval the output changes at every cycle, and while no scan runs every
edge falls about one poll (10 ms) after its mark. Without a scan an image
output is handed to the drivers as the committed output image (the image the
drivers got last, after the safe state of a stop or a fault) with the bit set,
and only when the bit changes, not at every pass. A failure to drive the output
ends identify, is logged, and never changes the state of the resource.
- Ending. identifying is true from the accepted request until the duration
has passed, and the output is off from the first of those points at or after
that moment. A repeated request while identify runs replaces it: the end is
the new duration after the repeat, and the blink starts again with the output
on. A pause, a stop, a fault and a restart of the program in place do not end
identify; it goes on in every state, and the program's outputs are as the state
says (a pause keeps their committed values; a stop or a fault applies the output
policy first, specification 34, STATE-02 and STATE-04, handing the drivers the
safe image without the identify bit, after which the blink goes on). An output
that [[io.safe_state]] names (the same bit, or a byte, word, double word, long
word or window of bytes that includes it) keeps its safe value while the safe
state of a stop or a fault is in force, from the stop or the fault until the
program scans again: identify does not blink it then, its time keeps running,
and the blink is back with the first scan. A GPIO line is not in the image, so
no [[io.safe_state]] entry holds it and it blinks in every state. A debugger
hold keeps the scan inside its cycle; trust-runtime on a computer ticks nothing
then, so the output keeps its level (specification 34, STATE-07), the time keeps
running, and the blink goes on when the scan goes on. The end of the process (a termination signal, the
explicit exit) ends identify and drives the output off. A restart that finds
the resource Stopped and loads the program again reads [runtime.identify] anew
(see Load): a run in progress ends with the program it was started on, its output
is driven off, and the setting of the project now (or none) is in force.
- Load. A project whose program uses the output is refused when
trust-runtime run or play loads it, before anything runs, with an error that
names the output. trust-runtime validate and check and trust-runtime ide serve
make the same check, so a project they accept is a project run accepts:
validate on the program of the bundle, check and ide serve on the program
that the project's sources compile to (they need no program.stbc; a project whose
sources do not compile, or whose io.toml cannot be read, has nothing to check,
check reports those errors itself, and ide serve still serves it so that it can
be fixed). A restart
that finds the resource Stopped and loads the program again makes the check as a
launch does, with the [runtime.identify] of the project as it is then; a project
that now fails it leaves the resource Faulted with the error, and the next
restart loads it again (specification 34, STATE-08). The errors:
- identify output %QX0.7 is used by the program: it binds %QW0 when a %Q
binding of the program (a bit, byte, word, double word, long word or window)
covers the bit; the text names that binding;
- identify output gpio:17 is used by the project: io.toml maps GPIO line 17
when an enabled gpio driver of io.toml maps the line as an input or an
output;
- identify output %QX2.0 is outside the output process image of 1 byte when
the byte lies beyond the output image that the program's %Q bindings make,
so the drivers always get an image of that length (bytes for a length other
than 1).
- Online change. A program installed while the runtime runs (bytecode.reload,
online change) is checked against an I/O-address output in the same way, with its
own bindings and output image, and is refused with the same errors for the
address; the running program stays. (A GPIO line is mapped only by io.toml,
which an online change does not change.)
- Status. status reports identifying (§6.8.1, the status projection).
- Command line. trust-runtime identify --endpoint <endpoint> [--token <token>]
--duration-s <n> sends the request to the runtime at that control endpoint, with
--token or else TRUST_CTL_TOKEN as its control token; --project <dir> reads
the endpoint and the token of that project's runtime.toml (an --endpoint or a
--token replaces what the project gives). It prints the result as one JSON line.
A refused request is a command failure that prints the runtime's error text and its
error code.
Presence. A found device keeps its last_seen time. A result seen again in
the same search is not listed twice: it moves its last_seen time forward and
keeps the stronger of its two confidences. A configured device that a running
runtime stops seeing for longer than its protocol's freshness window reports
connector state stale, with the time it was last seen.
Command line. trust-runtime discover runs the search on this computer, or
inside the runtime named by --endpoint, with --token or else
TRUST_CTL_TOKEN as its control token. --only (comma-separated),
--host (repeatable, targets.hosts), --cidr (targets.cidr), and
--deadline-ms fill the request; --confirm-fieldbus ethercat[:<adapter>]
(repeatable) sets confirm_fieldbus_scan and adds the bus to fieldbus. The
command prints each source's progress and each device when it is found or
changes, and it ends when the search ends. With --json each line is one JSON
object whose event member names it: source (a source's progress), device
(the device), merged (id and into), and last end (search_id, origin,
state, elapsed_ms, the number of devices, and warnings). A refused
request is a command failure.
--compare needs --project <dir> and runs the search on this computer, so it
cannot be combined with --endpoint. It compares the search with that project
(see "Compare with the project") instead of printing the devices. The search runs
the sources that the other options and the project's remembered choices select,
and it is directed at the project's devices, so that a device that is not found is
a device that was asked for and did not answer: the address of each Modbus TCP
driver, the broker of each MQTT driver, the host and port of each OPC UA
connection and the host of each ADS connection join the known addresses
(--host), and the unit IDs from 1 to 247 of the Modbus TCP drivers become
modbus.unit_ids. When the search has ended, the command prints the source
lines, then four sections, then the end line. A section is its heading, Missing,
Not in the project, Differs from the project or Not compared, a colon and its
number of entries (Missing: 1), and one indented line for each entry. A fact reads
as its name and its value, the facts in the alphabetical order of their names and
separated by ,, and a null reads none:
Missing: the protocol's name and the configured facts, asModbus TCP: address 10.0.0.9:502, unit_id 1;Not in the project: the device ID and the protocols, asdev:ip:10.0.0.12: Modbus TCP;Differs from the project: the device ID, the protocol's name, and for each differing fact its name, the project's value and the device's, asdev:ethercat:eth1:3: EtherCAT: position project 2, found 3, the facts separated by;;Not compared: the protocol's name, the configured facts and the reason in words (its source did not run in this search,its host name is not looked up,a search cannot probe this unit ID).
With --json the command prints the source lines, then one compare line, then
end. The compare line holds event, project (the folder) and every member that
comm.compare replies. The exit status is 0 whenever the comparison ran, whatever it
found; a project that cannot be read, or a search that cannot start, is a command
failure.
EtherCAT unavailable-resource contract¶
Creating an EtherCAT driver with a non-mock adapter does not require the adapter to be present, so project construction and runtime startup remain non-blocking. The first operation that needs the wire attempts bounded hardware initialization. If the adapter, configuration, or post-allocation hardware resource is unavailable, the operation returns an error and the connector is faulted rather than healthy.
EtherCrab process-data storage cannot be safely allocated repeatedly after a
post-allocation initialization failure. Such a failure is therefore terminal
for that driver instance: later reads and writes return the visible
ethercat hardware unavailable until driver rebuild error without another
allocation attempt or retry window. Recovery requires constructing a new
driver instance. This terminal rule prevents an unavailable adapter from
causing unbounded allocation, retry, or scan-cycle delay. An identification
opens at most one process-data storage per adapter for the whole process and
reuses it for every later scan of that adapter, so repeated searches allocate no
further storage. Every one of those scans re-enumerates the bus on that
adapter's main device, so a device added after an earlier scan is found by the
next. adapter = "mock"
proves deterministic software behavior only and is not evidence that physical
EtherCAT hardware is available or production-ready.
- Modbus/RTU
- Driver name:
modbus-rtu, on every target (owner, 16 October 2026): a serial port of the operating system on Linux, macOS and Windows, for example/dev/ttyUSB0orCOM3, and a UART of the chip on a board, for exampleUSART2on the STM32U5A9 (specification 34, TARGET-01). - truST is the Modbus client on an RS-485 or RS-232 line;
unit_idnames the server. - Functions, default profile,
input_points/output_points, payload order, scaling,byte_order/word_order, floating-point finiteness and theon_errorpolicy are those of Modbus/TCP above. One implementation of the Modbus application layer serves both drivers; only the framing and the link differ. - Serial parameters:
port,baud,parity(evenby default,oddornone),stop_bits(1, or 2, whichparity = "none"requires), eight data bits, andtimeout_msfor the response. A combination that the Modbus serial line specification forbids is rejected before the worker starts. - RTU framing: each frame ends with the Modbus CRC-16; frames are separated by
at least 3.5 character times and characters within a frame by at most 1.5
character times, with the fixed 1.750 ms and 750 µs above 19,200 baud. A frame
with a CRC mismatch or an interrupted character stream is discarded and
counts as a transport failure under
on_error. - RS-485 direction: on a chip, an optional
direction_pindrives the transceiver's driver-enable line while a request is sent; on a computer, the serial adapter controls the direction. - Exchange is worker-backed as for Modbus/TCP: a serial round trip never runs inside the scan. On a chip without threads the runtime serves it between cycles.
- Reference: Modbus over Serial Line Specification and Implementation Guide V1.02.
6.6 Fault, Overrun, and Watchdog Handling¶
The runtime traps execution faults and reports them through a unified fault channel. By default, a fault transitions the resource into a FAULT state and halts further task execution until restarted.
Faults include: - Arithmetic errors (e.g., divide by zero) - Out-of-bounds accesses - Invalid type conversions - FOR loops with a step expression that evaluates to 0 (guarded by bytecode and treated as a runtime fault) - Task overruns (missed deadlines)
Overrun policy (default): if a periodic task misses its deadline, the missed activation is dropped, the overrun counter increments, and the task is eligible again on the next interval boundary.
Watchdog policy (production): - A watchdog monitors cycle/task execution time. - If the watchdog timeout elapses, the runtime raises a FAULT and halts the resource. - The output-commit deadline is checked before any physical driver write. If it has elapsed, the pending process-image output is not sent to a driver. - Timeout thresholds, fault actions, debugger interaction, automatic recovery, and the physical driver commit boundary are configured by the truST host runtime (see §6.9). IEC 61131-3 does not define these host mechanisms, so they are product contracts here rather than IEC deviations. - Default action is safe_halt: outputs are set to configured safe values (if provided), then the resource halts. For halt and safe_halt, safe-state outputs are applied before halting. If no safe-state output values are configured, fault handling performs no physical output write; it must not re-send the pending process-image output as a substitute safe state. - If a pre-commit fault occurs after a pending image has been staged, the runtime restores the last physically committed output image before applying configured safe values. A partial safe-state map overrides only its named addresses; every unconfigured output therefore retains its last committed value and cannot leak the failed scan's pending value.
Debug and resource pause interaction: - The watchdog measures active cycle execution, not operator dwell time at a debugger statement-boundary pause. Time spent waiting in that paused state is excluded from the current cycle's execution and output-commit deadlines. - A resource pause accepted between cycles does not arm a new watchdog deadline and does not start another input, task, retain, or output phase while paused. - A debugger pause inside a cycle suspends that cycle. It does not start another scan or commit outputs while waiting. Resume continues the suspended cycle with the same remaining active-execution budget; it does not grant a fresh full watchdog interval. - Pause alone does not apply safe state or clear a fault. A real active-execution timeout before or after the paused interval still follows the configured watchdog fault action.
These pause rules are a truST host/debugger contract outside the IEC language execution model, not an IEC deviation.
Automatic restart policy:
- A non-panic cycle fault configured with FaultPolicy::Restart, or a watchdog
timeout configured with restart action, enters the same warm-restart storage
transition defined in section 6.7: RETAIN values are preserved while
NON_RETAIN and ordinary initialized storage are reinitialized.
- A contained ResourcePanic is never automatically restarted. It remains a
visible fault so an unexpected unwind cannot be hidden by retry.
- Automatic restart attempts are bounded to three consecutive attempts for the
same unresolved fault. Exhaustion leaves the resource faulted with a
diagnosable restart-limit error.
- Consecutive automatic retries use a nonzero monotonic backoff derived from
the configured cycle interval. The base is clamped to the inclusive range
from one millisecond through one second, later attempts increase
exponentially, and the resulting delay never exceeds one second.
- The restarted program's tasks' timing, and the cycle grid with it, begin when
its first cycle starts, after the backoff (specification 10 §4.3), so its
tasks count no deadline in the backoff.
Faulted resource lifecycle (hosted runtime):
- A fault that halts the resource (policy Halt or SafeHalt, a watchdog
halt or safe_halt action, an exhausted automatic restart, or a failed
restart) leaves the resource in FAULT after the fault action has run. The
runtime process keeps running: the control plane, web UI, HMI and protocol
servers stay available, status reports the fault, and last_error keeps the
root fault.
- The faulted resource executes no task and commits no outputs. It waits for a
recovery request: restart (warm or cold) runs the configured initialization
and retain policy and then resumes cyclic execution (specification 34
STATE-06); an online change prepares and commits as in section 6.7.1 and then
resumes; shutdown stops it as it stops a running resource (specification 34
STATE-08): the safe state is applied again, the retained values the variables
hold are saved, with the stores of the faulted scan's statements that ran (the
values a warm restart from the fault keeps, below "State after a faulting
scan"), and the resource is Stopped, or stays Faulted with that error when the
safe state is unconfirmed or the save fails; a termination signal or the
explicit exit (section 6.9) stops it in the same way and ends the process. A
failed recovery leaves the resource in FAULT with the new error. pause and
resume are rejected while faulted.
- The root fault stays in last_error, and status reports it, until the next
start: a stop of the faulted resource keeps it, and a restart that starts the
program clears it.
- The runtime process ends only on a termination signal, on the explicit exit,
shutdown with {"exit": true} (specification 34 STATE-08), or on the hang
action abort below. A plain shutdown leaves the process running. The
process exits with status 0 when the resource ends Stopped and with status 1
when it ends Faulted or Stopped with the fault of the program it stopped, so a
service manager can tell a clean stop from a failure.
State after a faulting scan (hosted runtime):
- A scan that faults while its programs execute is not undone. The variables keep
every store that the scan completed: the queued writes and forces it applied
and the inputs it sampled at its boundary (specification 10 §4.7, step 2), and
the statements that ran before the failing one. The failing statement stores
nothing. Rollback exists only inside a single transaction (the start-of-cycle
input synchronization of section 6.4, the output copy-back of a call, the flat
output image of a failed output commit in specification 10 §4.7); none of them
reverts a statement that completed. IEC 61131-3 Ed.3 leaves the effect of an
error during execution to the implementer (§5.1 e and its note; §6.6.1.5,
rules 3 and 4), so this is a truST runtime contract, not an IEC deviation.
- Such a scan reaches no output phase: the process image and the drivers keep the
values of the last completed scan unless the fault policy applies configured
safe values (above), the retain checkpoint of the scan is not saved, and the
cycle number does not advance.
- The writes the scan applied were one-shot and are consumed. The fault is a
clearing boundary (section 6.9.2), so the forces that were active are removed;
removing a force writes no replacement value, so a forced variable keeps the
last value the scan applied to it.
- A later scan request is refused with ResourceFaulted before it applies any
queued write or force (specification 10 §4.7, step 1). Section 6.9.2 states what
the faulted resource does with new writes and forces.
Hosted hang supervision (specification 34 TIME-03):
- The checks above run on the scan thread, so none of them can act while that
thread is inside a driver or native callback that does not return. With the
watchdog enabled, the hosted runtime therefore runs an independent supervisor
thread per resource.
- The scan thread publishes the active-execution deadline of the cycle it is
running and withdraws it when the cycle ends. A debugger hold suspends the
deadline exactly as it suspends the watchdog, so operator dwell time never
counts.
- The supervisor checks the published deadline at least every
clamp(timeout / 4, 1 ms, 100 ms). A cycle still running one check interval
after its deadline is hung. The supervisor then sets the resource to Faulted,
records WatchdogTimeout as last_error with the detail that the scan did not
return, and applies runtime.watchdog.hang_action:
- fault (default): nothing more. The process keeps serving the control
plane, status reports the fault, and requests that need the scan thread
(pause, resume, restart, online change) are rejected with the stable
error that names the resource state, because only a new runtime process can
recover a thread that does not return.
- abort: after recording the fault, the process exits at once with status
70, so an external service manager can restart it.
- truST cannot drive outputs while the scan thread is blocked, because that
thread owns the drivers and the process image. A hang therefore relies on the
I/O devices' own communication-loss behaviour, which installations must
configure; it does not claim a confirmed safe state.
- If a call the supervisor declared hung returns later, the scan thread finds
its deadline expired and follows the configured watchdog action for an
overrun; the cycle's outputs are not committed.
- The maximum time from a deadline to the recorded fault is two check
intervals. Detecting and faulting a hang is a protection result, not
completion of the missed control deadline.
- With the watchdog disabled there is no supervisor and a hang is not detected;
the configuration documentation states this.
- A device without an operating system has no supervisor thread: its hardware
watchdog resets it when a pass of its loop does not return, whether or not the
watchdog is enabled (specification 34 TIME-04). The boot that follows starts the
program and applies hang_action before the first scan: with fault the
resource is Faulted with WatchdogTimeout until a restart, and with abort
the program runs, as a service manager starts a computer's process again. After
a panic, which resets such a device, the resource is Faulted with
ResourcePanic and the panic's location (specification 34 TARGET-08).
Per-cycle deadline lifecycle: - Before executing a cycle with an enabled watchdog, the scheduler arms both the execution and output-commit deadlines from the same wall-clock start and configured timeout. A disabled watchdog leaves both deadlines unchanged. - The scheduler restores the deadlines that existed before the cycle after normal completion, an ordinary runtime error, or a contained cycle panic. Temporary per-cycle deadlines therefore cannot leak into later work. - Enabled non-positive policy values are normalized before installation as specified below. As a defensive helper boundary, a zero timeout that reaches deadline construction produces an immediately expired deadline rather than disabling enforcement or extending the cycle.
Fault-state clear lifecycle¶
The portable fault holder clears its current fault as one state transition:
faulted becomes false and the stored last fault becomes absent. Clearing does
not change the configured FaultPolicy. Calling clear while already healthy is
idempotent and leaves the healthy state, absent last fault, and policy
unchanged.
This clear operation is bookkeeping at the portable holder boundary. It does not restart or resume a resource, apply safe outputs, reset program or retained storage, prove that the underlying fault was repaired, or authorize a caller to recover production execution. Those actions remain owned by the host recovery and control boundaries. This is a truST runtime API contract, not an IEC deviation.
The installed hosted runtime's clear_fault exposes this bookkeeping clear for
tests and engineering tooling. A scan executed after it continues the existing
program without any initialization, so it is not a recovery path. Product
recovery, through control commands or the Restart fault policy, always runs
the configured warm or cold restart before control resumes (specification 34
STATE-06).
Fault-policy decision projection¶
The portable fault holder projects its configured FaultPolicy to a
FaultDecision using this closed table: Halt returns action Halt with
apply_safe_state = false; SafeHalt returns action SafeHalt with
apply_safe_state = true; and Restart returns action Restart with
apply_safe_state = false. The projection reads only the current policy. Its
result is unchanged by whether the holder is healthy or faulted and by the
presence or value of the stored last fault. It does not mutate any of those
observations.
A FaultDecision is an instruction to the owning host-control path, not proof
that its action occurred. Producing it does not itself apply safe outputs, halt
or restart a resource, clear a fault, reset storage, consume a retry, or
authorize recovery. Those effects remain governed by the safe-state, automatic
restart, and recovery-control contracts above. This is a truST runtime API
contract, not an IEC deviation.
Fault recording and policy replacement¶
Recording a runtime fault sets the portable holder's faulted observation to
true and stores the supplied RuntimeError as the exact last fault. A later
record replaces the earlier last fault. Recording does not change the
configured FaultPolicy, execute that policy, clear state, apply safe outputs,
or restart a resource.
Replacing FaultPolicy stores the supplied policy exactly. It does not change
the current faulted flag or stored last error, including when policy is changed
while the holder is already faulted. Policy replacement returns no evidence
that the corresponding halt, safe-state, or restart action was executed. These
are truST runtime API contracts, not IEC deviations.
Watchdog policy normalization and installation¶
Before an enabled WatchdogPolicy is installed, a timeout less than or equal
to zero is normalized to one millisecond so the scheduler cannot receive an
always-expired enabled deadline. A positive enabled timeout is preserved.
Disabled policy timeouts are inert at this boundary and remain unchanged. The
configured WatchdogAction is preserved in every case.
WatchdogSubsystem::set_policy replaces the previous policy with that exact
normalized value. Installation does not arm a deadline, report a timeout,
apply safe state, halt, or restart a resource; those effects remain owned by
the scheduler and host-control paths. The subsystem decision projection reads
the installed action without mutating the stored policy. These are truST
runtime configuration contracts, not IEC deviations.
Portable watchdog and fault configuration model¶
Configuration token parsing trims surrounding whitespace and compares ASCII
case-insensitively. WatchdogAction and FaultPolicy accept exactly halt,
safe_halt, and restart; RetainMode accepts exactly none and file.
Every other token returns RuntimeError::InvalidConfig. The diagnostic
preserves the complete original, untrimmed text in one of these exact forms:
invalid watchdog action '<text>', invalid fault policy '<text>', or
invalid retain mode '<text>'.
The default WatchdogPolicy is disabled, has a zero duration, and selects
SafeHalt. Both WatchdogSubsystem::new and Default store that policy.
Their decision projects Halt to (Halt, apply_safe_state = true),
SafeHalt to (SafeHalt, true), and Restart to (Restart, false).
The watchdog Halt projection therefore deliberately differs from the
ordinary FaultPolicy::Halt projection: a watchdog timeout requires safe
output handling before halt.
Both FaultSubsystem::new and Default select FaultPolicy::Halt, report a
healthy state, and contain no last fault. Reading policy, decision, faulted
state, or last fault does not mutate the holder. FaultInfo stores the exact
human-readable reason supplied by the host, and cloning it preserves that
text. Constructing or reading these portable model records does not execute a
halt, safe-state write, restart, retain operation, or recovery action.
6.7 Retain Storage (IEC 61131-3 §6.5.6)¶
Retentive variables must follow IEC 61131-3 retentive variable rules (§6.5.6, Figure 9). At startup:
- Warm restart: RETAIN variables restore their retained values; NON_RETAIN are initialized.
- Cold restart: RETAIN and NON_RETAIN variables are initialized.
- Unqualified variables follow the runtime's retain policy (see
docs/IEC_DECISIONS.md). VAR_STATfollows the documented vendor-extension storage rules: function statics persist across calls, method statics persist per instance and per method, andPROGRAM/FUNCTION_BLOCK/CLASSVAR_STATuses ordinary instance storage.
Every runtime.toml that truST writes keeps retained values in retain.bin beside it
([runtime.retain] mode = "file", path = "retain.bin"): a new project, a device's setup (setup,
the web setup and the wizard) and a runtime created in the config UI. RETAIN variables therefore
survive a warm restart and a power cycle without further configuration, as on every PLC;
mode = "none", which makes every start cold, is the user's own choice.
Retain storage is provided via a pluggable backend:
pub trait RetainStore: Send {
fn load(&self) -> Result<RetainSnapshot, RuntimeError>;
fn store(&self, snapshot: &RetainSnapshot) -> Result<(), RuntimeError>;
}
Retain snapshot identity and order¶
The portable retained snapshot receives already-resolved logical names. It does not canonicalize, case-fold, or otherwise reinterpret a supplied key. The first insertion of a name appends one entry after all existing entries. Inserting the same resolved name again replaces only that entry's value at its original position: snapshot cardinality, sibling values, and sibling order do not change. A different name remains a distinct entry and appends in call order.
Configuration/global retained variables keep their resolved logical name.
Retentive variables declared inside a PROGRAM use the reserved internal
identity @program/<program-identity>/<variable-identity>. The @program/
prefix cannot be an IEC identifier, so it cannot collide with a declared
global. Program and variable identities retain their reviewed spelling in the
file and resolve ASCII-case-insensitively on load. Snapshot creation visits
programs in runtime registration order and variables in declaration order.
Program-variable entries participate in the same full-snapshot validation,
migration, orphan reporting, and atomic apply boundary as retained globals.
The variables of function block and class instances are retentive as IEC
61131-3 §6.5.6.1 and §6.5.6.2 say, with the reading of docs/IEC_DECISIONS.md
(2026-10-23) for the unqualified variables of a nested instance. An instance
declared RETAIN or PERSISTENT (a global or a program's variable, alone, as
an element of an array of instances or as a member of a structure) is kept as a
whole: it keeps all its variables, those its base declares included, except
those its type declares NON_RETAIN. A variable that is itself an instance, or
holds instances, is kept as a whole when it is declared RETAIN or
PERSISTENT, or when it is not qualified and its enclosing instance is kept as
a whole; declared NON_RETAIN, it is not. In every instance a variable its type
declares RETAIN or PERSISTENT, whether a local, an input or an output, is
kept, and one it declares NON_RETAIN starts over, also in an instance kept as
a whole. A configured program instance's qualifier (PROGRAM RETAIN P : T)
qualifies the program's other unqualified variables but keeps no instance it
holds as a whole. A retained variable of an instance is written under the key
@global-instance/<holder>/<member path> for an instance a global holds and
@program-instance/<program-identity>/<holder>/<member path> for one a
program's variable holds. <holder> is the identity of the holding variable
followed by each field on the way to the instance, written . and its
identity, and by each array element, written as its indexes in brackets
separated by commas (Pumps[2], Grid[1,0]); <member path> names the
retained variable inside the instance, through each nested instance by its
identity, the indexes of its element and a . (Inner.Total,
Cells[1].Inner.Total). A variable that an instance inherits from its base
stands at the derived instance's level and adds no segment for the base (IEC
61131-3 §6.6.5.3 forbids redeclaring an inherited name, so the paths stay
unique): @program-instance/Main/Line/Pumps[2].Hours,
@global-instance/Fleet[1,3]/Count. These entries follow the retained globals and the programs' own retained
variables, in one depth-first walk: the globals in declaration order, then the
programs in registration order, each one's variables in declaration order; an
instance's inputs, outputs and in-outs in declaration order, then its other
variables in declaration order, then the variables of its base the same way;
an array's elements in index order, the last index changing fastest; a
structure's fields in declaration order. The walk follows the instances a
variable holds, not those a reference or an interface names, and it reaches a
retained variable by the same path at every capture. The variables of a standard
function block are its inputs and outputs; its own state, which no declaration
names (a timer's start), starts again at every restart. A key that names a
variable of an instance declared without retention leaves it alone and is not
reported, as for a program's variable. These entries take part in the same
validation, migration, orphan reporting and atomic application as the others,
and a warm restart in the running process keeps them as it keeps the others.
Insertion stores the complete runtime Value without changing its concrete
variant or nested contents. This is a deterministic snapshot-construction
contract: constructing a snapshot from an ordered map, borrowing its values,
and consuming it back into a map preserve the exact key spelling, entry order,
runtime value variants, and nested values without normalization or
reconstruction.
contract, not a validation bypass: declared-type migration, non-finite value
rejection, serialization, and atomic application remain owned by the retain
save/load/apply boundaries below. Successful insertion alone does not mean a
snapshot is valid for persistence or warm reload. IEC 61131-3 Ed.3 section
6.5.6 defines retentive variable behavior but not this internal container
policy; the truST choice is specified here and is not an IEC deviation.
The runtime loads retained values during resource startup and writes them on shutdown and periodically (policy defined in the runtime configuration). The periodic cadence is rate-limited and only writes when retained values have changed.
The file-backed retain boundary accepts REAL and LREAL values only when
they are finite. This rule is recursive through retained arrays and structures
and applies to both serialization and deserialization, including legacy retain
images. A rejected save does not replace the last durable snapshot. A loaded
snapshot containing NaN, positive infinity, or negative infinity is rejected
as a whole before any retained global is applied; the runtime does not clamp,
normalize, or substitute a default value. In-process floating-point execution
outside the file-backed retain boundary is unaffected by this rule.
Retain-store failures use the following fail-closed transaction policy. IEC 61131-3 section 6.5.6 defines which variable classes are retentive across a restart; the storage and failure boundaries below are the truST runtime contract:
- A missing configured file represents an empty snapshot. Open, read, framing, checksum, decode, declared-type migration, and value-compatibility failures are errors. A warm startup or warm reload that encounters one of these errors fails instead of continuing with a partially restored image.
- The complete loaded snapshot is decoded, canonicalized, and validated before any retained global is changed. If any retained entry is invalid, every retained global keeps the value it held before the load attempt.
- A save is encoded before it is published and is written through a temporary file in the destination directory. Failures before the atomic rename leave the previous file unchanged. A rename or parent-directory synchronization failure is reported as an error; the runtime does not claim that the new snapshot is durable. The in-memory snapshot remains dirty so a later save may retry.
- A due periodic save failure fails that scan before physical outputs are committed. A requested-stop save failure is exposed as the resource's retain error rather than reported as a successful stop.
- Cold restart does not load the retain store and therefore cannot silently reinterpret a failed warm-load attempt as cold-start success.
Retain codec compatibility and migration¶
The file-backed retain store preserves the logical snapshot across a successful
store/load round trip. The supported codec preserves exact names, order, and
the concrete values asserted by the compatibility profile: Boolean and integer
scalars, bounded arrays with their inclusive bounds, structures with their type
and field identity, and enumeration values with their declared type and
variant. This includes qualified retained PROGRAM variables. A warm restart
loads and applies the saved snapshot after rebuilding program instances; a
cold restart initializes retained variables and does not load the store. A
missing file alone represents an empty snapshot; a path that exists
but is not a readable retain file, or a parent path that cannot contain the
file, returns the underlying retain operation as an error instead of silently
substituting an empty snapshot.
The current checksum-protected format is version 3. Version-1 and version-2
STRN snapshots remain readable for the value tags supported by those formats;
a decoded value materializes with the same logical name and runtime value. The
version-3 payload codec is shared by computers and chips: a chip's checkpoint
records carry exactly these payload bytes (specification 34 §10.1), so both
write and read the same snapshot with one codec.
The payload bytes of a retained value do not depend on where the runtime keeps the value. A value held in
the bounded cell store (specification 34 §6.2) is encoded from its cells, its text buffers and its compound
members, without first building a Value, and the bytes are those of the same value held as a Value: a
structure with its type name and its member names, an array with its bounds, an enumeration with its type
and variant names, a WSTRING as its UTF-8 text, and an empty cell range as the null value. A reference, an
instance and a non-finite REAL or LREAL are refused with the same error whichever way the value is held.
Those formats store structures as logical type/field values only. When an old
snapshot is migrated to a declaration with relative physical storage, the
runtime rebuilds backing from the admitted declaration and logical fields,
zeroing unnamed gaps; old files never promised to preserve hidden physical
bits. Successful
replacement publishes one complete snapshot and leaves no retain temporary
file. Parent-directory synchronization occurs after rename. If that final sync
fails, store returns an error and does not claim durability, but a read in the
same filesystem session may observe the already-renamed new snapshot. The
manager keeps the snapshot dirty and permits the next due save to retry without
another mutation notification.
Version 3 adds an opaque physical checkpoint to a structure value when that
value owns admitted relative-layout backing. The checkpoint carries the storage
ABI, complete extent, leaf and child paths, scalar encodings and constraints,
and backing bytes, including unnamed gaps. Values without relative physical
backing retain the existing logical structure representation. The runtime
validates checkpoint framing, counts against remaining input before allocation,
path depth, UTF-8 names, extents, encoding widths, and every logical scalar leaf
against the backing before the snapshot can be returned. Checkpoint lengths,
counts, string lengths, and extents use their checked u32 wire fields; the
decoder also uses fallible reservations and rejects values not representable by
the host. Invalid checkpoint data rejects the complete load.
Retain save suppression compares physical checkpoints recursively in addition
to ordinary logical value equality, so hidden-byte-only mutations are durable
without changing the language-level equality of structure values. It also
compares serialized floating-point bit patterns, including signed zero, before
skipping a save.
Warm migration attaches the checked checkpoint only to an equivalent admitted
layout; incompatible ABI, field paths, offsets, scalar codecs, enum constraints,
extent, or removal of physical semantics rejects the entire application
without mutation. The checkpoint is internal persistence data and does not
authorize caller-supplied physical layouts or raw installed-state writes.
Before applying a loaded snapshot, the runtime resolves every retained name and stages all compatible migrations:
- a safe declared numeric widening such as
INTtoDINTpreserves the mathematical value; - a retained
STRINGorWSTRINGlonger than its current declared capacity is cut to that capacity by the ordinary store rule of specification 10 §2.3.1 (UTF-8 bytes or UTF-16 code units, never splitting a character; 80 without a declared length); - compatible enumeration values are rebuilt with the current declared enum type and variant identity, including the reviewed case-canonical spelling;
- compatible structures recursively rebuild the current declared structure and field identities, and compatible one-dimensional arrays preserve their inclusive bounds while recursively rebuilding each element;
- a structure field added by the current declaration receives its declared initializer or type default, while a field removed from the declaration is dropped; and
- a retained global absent from the current program is dropped as an orphan.
An incompatible nested structure field or array element rejects application; the runtime does not substitute a default or expose a partially rebuilt aggregate. These focused rules do not establish arbitrary-depth, multidimensional, renamed, removed-variant, or event-emission behavior.
Each successful value-shape migration emits RetainMigrationApplied; each
dropped orphan emits RetainOrphanDropped. These events identify the runtime
transition, not a stable debug-format string. Migration remains transactional:
all entries are validated and staged before any retained target changes, so one
incompatible entry rejects the whole snapshot without exposing earlier staged
values.
Power-loss guidance: retained values are only guaranteed to persist if the most recent
snapshot has been flushed to the retain store (i.e., at shutdown or after the save cadence).
Here, shutdown means the explicit requested-stop path. Plain destruction of a
Runtime or TestHarness is not a graceful-stop request and does not
implicitly publish dirty retained state. Unflushed changes may be lost on
sudden power loss (implementer-specific).
Direct restart preparation transaction¶
An in-process warm restart constructs its complete replacement storage and instance image before changing live state. If reviewed function-block instance construction fails, the restart returns an error and preserves the live executable, storage values, queued debug force, and cycle counter. This direct restart boundary does not independently specify task schedules, logical time, I/O bindings, process-image state, or a stable diagnostic string.
Directly represented variables at restart¶
A variable that is declared with a direct address at an input or an output
(AT %I or AT %Q and an address) is initialized by a restart under the rule
of its declaration, like every other variable (IEC 61131-3 Ed.3 §6.5.6.1, Figure 9,
with the policy at the head of this section): a warm restart gives a RETAIN or
PERSISTENT variable its retained value and every other variable its declared
initial value, and a cold restart gives every variable its declared initial
value (§6.5.1.3 and §6.5.6.2, whose example declares
VALVE_POS AT %QW28 : INT := 100 in a NON_RETAIN section). For these
variables the initial value belongs to the variable: its initializer writes no
byte of the process image, at a restart or at any other time.
The process images are carried across the restart (specification 34 §10) and
meet the variables again at the next scan:
- a variable located at %Q shows its initial or retained value in the output
image when the first scan after the restart completes (end-of-cycle output
synchronization, section 6.4). Until then the image, and the outputs that the
drivers hold, keep the values of the last completed scan, as after a fault;
- a variable located at %I takes the sampled input image at the start of every
scan (section 6.4), so its initial value is visible only until the first scan
starts. IEC 61131-3 Ed.3 §6.5.6.2 and the note to §6.5.5.3 leave the
initialization of system inputs to the implementer; truST lets the input image
win.
The only initializers that write a process image are the ordered configuration actions whose target is a direct address, such as process-image contents seeded through the hosted builder (specification 34 §10). They run after the carry-over, at every restart and every online change, and set exactly their selected bytes, bits and hierarchical keys.
A variable located at %M (AT %M and an address) is initialized under the
same rule of its declaration, but it is bound in both directions: the scan loads
the marker image into it when the scan starts and publishes it into the image
when the scan completes (section 6.4). A marker image that still held zero, or
the carried-over image, at the first scan would therefore replace the variable's
initial or retained value before the program ran. IEC 61131-3 Ed.3 exempts only
the system inputs from initialization (§6.5.6.2 and the note to §6.5.5.3), so the
value reaches the image first:
- construction, every cold or warm restart and every online change publish the
value of every variable located at %M into the marker image once the
variable holds its initial or retained value, and before the first scan. The
value is the declared initial value at the first start, for a NON_RETAIN or
unqualified variable in a warm restart and for every variable in a cold
restart, and the retained value for a RETAIN or PERSISTENT variable in a
warm restart and a warm restart from the store. At an online change it is the
value the variable carried from the running program, whatever its qualifier,
or its initial value when it carried none (section 6.7.1). A restored or
carried value is published after it has been restored, so it, not the
carried-over image, decides the image;
- the publication writes exactly the bits and bytes that the variables are bound
to. Every other bit and byte of the marker image keeps its carried-over value,
and %I and %Q are not touched (above);
- it runs after the ordered configuration actions, so the bits and bytes that a
located variable covers hold the variable's value even where an action had
written them. A seeded image records an action for the last byte of each of its
non-empty areas, and that action writes zero when nothing was seeded there; it
must not clear the high byte of an initialized marker word. Bits and bytes that
no variable covers keep what the actions set;
- it belongs to the initialization. A binding that cannot encode its variable's
value fails the construction, the restart or the online change like any other
initializer failure, and the publication changes no image when it fails;
- a binding-only update, which resets no PLC value, also publishes: in the commit
that installs the bindings and the image sizes it publishes the current value of
each marker binding that it adds or changes, and of no other, so that a marker
byte that a driver wrote since the last scan survives, and a refused update
leaves the old bindings and images.
6.7.1 Online-change transaction¶
Online change is a truST runtime extension; IEC 61131-3 does not define its transport or transaction boundary. A reload request is applied only at a completed scan boundary. The runtime must prepare every fallible input needed by the change before replacing live execution state:
- admit the new program image (specification 44 §10.2) and load its module;
- validate the selected resource, tasks, and process-image sizes; and
- construct the candidate's variables and instances in staged storage, with all fallible function-block, class, and program instance initialization, and carry the running program's values into them (below).
An image holds one resource (specification 44 NX-03), so a change names none; the runtime keeps its resource identity.
A reload request waits up to five seconds for the resource to take it. If the resource has not started applying the request by then, the request is withdrawn and never applied, and the reply reports the timeout; once the resource has started applying it, the reply reports the commit or preparation result. A reload therefore never commits after its caller was told it timed out.
If preparation fails, the request returns an error and leaves the prior
executable module, task schedule, variable storage, process image and bindings,
debug mutations, logical time, cycle counter, and runtime fault/status state
unchanged. The old program remains executable on the next scan. A value never
fails the preparation (below).
A byte sequence that is no program image, such as [00, 01, 02, 03], fails
preparation as RuntimeError::InvalidBundle with the admission's reason
(specification 44 §10.2); no stable message text is implied.
After successful preparation, the commit replaces the program as one
cycle-boundary operation, restarts at the program entry point, and rebinds I/O.
The candidate's variables keep the values the running program holds (below).
Logical time is preserved, and the candidate's tasks continue the running
program's schedule (specification 34 §11.3, decision OC-2): a task takes the
last activation, the level of its event input and the overruns of the running
program's task of the same name, compared without regard to case, so the first
scan after the commit runs the tasks that are due; a task that the running
program does not have counts its first activation from the program's logical
time at the commit, which is the time of the last cycle that started before it,
since the program's time advances when a cycle starts: it first runs in the
first cycle that starts one interval or more after that time (a 1 s task added
by a change committed 135 ms after the last cycle's start first runs about 0.9 s
after the commit). The scan counter restarts, and queued writes and forces are
cleared under section 6.9.2. A request must never report reloaded unless this
complete commit succeeds.
The replacement installs the next generation of the running program's lineage, so a reference or an instance identity taken before it is refused as stale after it (specification 34 §11.3, UPDATE-04), and it carries the process images and the logical time.
The candidate's variables take the values that the running program holds in memory at the boundary, on every runtime, retained or not, as CODESYS and TwinCAT keep them (specification 34 §11.3; decision OC-1, 19 October 2026, and "keep what fits", 20 October 2026):
- a variable of the candidate that the running program declares at the same place and by the same name, names compared without regard to case, takes the running value: a global, a function static, a variable of a program instance (the program instances matched by their names), and the members of every function-block or class instance they hold, recursively, edge memories and the internal state of a standard function block such as a timer included (the internal state only into an instance of the same function block);
- a variable whose declaration changed keeps what fits: an elementary value is
converted to its new type when that type holds it, which is when the value
converted to the new type and back comes out unchanged (
INT5 toDINT,DINT5 toINT, aSTRING[10]"abc" toSTRING[5], a value inside a new subrange, an enumeration value by its variant's name); a structure keeps its fields by name, an array the elements at the indices both arrays have, an instance its members by name; anything else, a value the new type does not hold (aDINT70,000 in anINT, a running value100in anINT(0..10), a text longer than the new length) and a structure with a physical layout whose declaration changed, keeps its declared initial value; - a reference or interface value names the same variable or instance in the candidate when the candidate has it with the type the reference names, and is NULL otherwise;
- a variable that the running program does not declare starts with its declared initial value; constants keep the candidate's values, in-out members are bound again by every call, and a static or an internal state that the running program has not set yet is not carried.
Nothing is applied over these values: the change reads no retain store, because the retain file or checkpoint holds older values, saved at the configured interval and when the program stops, and stays the source of a warm restart after a stop or a power loss (section 6.7). A retain store whose read would fail therefore cannot refuse a change. No value fails the change. A change that recovers a faulted resource keeps the value that caused the fault, so the candidate's code must handle it.
Each variable of the candidate that does not go on with the running value as it
was (a new variable, a converted value, a value that does not fit, a reference
set to NULL) is recorded at the commit as an online_change_value event with
the variable's path as name (total, Plant.motor.speed, Plant.table[3])
and what happened as detail, at most 32 events per change and then one event
whose name is null and whose detail gives the number of further variables.
The events follow the order of the candidate's symbol table, which sorts the
names in capitals (specification 44 §5.7), so a change gives the same list on
every runtime.
6.7.2 Runtime agent command conformance¶
The runtime implementation of the Agent API preserves the canonical workspace identity and validates the requested method and workspace-relative path before performing an operation. Its project, workspace, diagnostic, formatting, build, validate, test, runtime, and harness methods return the typed payload defined by the Agent API contract. Unknown methods and invalid paths return the stable reviewed error class instead of a false successful payload. Bounded test and harness loops must terminate at their requested case, iteration, or deadline boundary.
runtime.compile_reload completes diagnosis and compilation before requesting
the live reload. runtime.reload rebuilds the selected project before
replacing the live executable. A compilation, preparation, or reload failure
is returned as failure and leaves the current live runtime available; neither
method may report a successful reload until the complete replacement
transaction has committed. These runtime obligations specialize the general
Agent API method contract and do not widen its public method set.
6.8 Runtime Launcher & Deployment (Project Folder)¶
Production runtimes are started via the CLI (trust-runtime run) using a project folder
(runtime bundle format) directory. The launcher is responsible for:
- Loading the program image (
program.tpi) thattrust-runtime buildwrote and admitting it on this computer, with the statement budget of[resource] max_statements_per_scanand the trusted keys of the computer'sdevice.toml(specification 44 §8.8, §10.2, §5.11). A project withoutprogram.tpiis refused with "missing program.tpi at(run trust-runtime buildto build the project)", and one that holds only the bytecode engine'sprogram.stbcwith "the project holds program.stbc of the bytecode engine, which this runtime does not run: rebuild the project withtrust-runtime build --project <dir>". - Loading runtime configuration (
runtime.toml). - Initializing I/O drivers (
io.tomlor system IO config). - Initializing retain storage (if configured).
- Exposing a control endpoint for local attach/debug.
- Validating bundle version compatibility before execution (internal
bundle.version).
The launcher must run on Linux, Windows, and macOS (desktop targets). Embedded targets may replace the launcher with platform-specific init systems while preserving the same configuration and control protocol.
If a project folder omits io.toml, the launcher loads the system IO config
This host-side launcher policy is a truST product contract. IEC 61131-3 does not define hardware-driver selection or OS-level I/O configuration, so the policy is not an IEC deviation.
Windows runtime executable stack link contract¶
The trust-runtime Cargo build script always declares itself as a rebuild
input. For a Windows target it assigns the trust-runtime executable an
8 MiB stack: MSVC targets emit /STACK:8388608, while other Windows toolchains
emit -Wl,--stack,8388608. Non-Windows targets emit no executable stack linker
argument. This is a build-platform contract for the runtime executable; it
does not change PLC task stack limits or establish runtime stack-usage bounds.
Runtime bundle builder source and dependency resolution¶
Without an explicit source override, the runtime bundle builder selects the
project's src/ directory. A project containing only the legacy sources/
directory rejects with the reviewed missing-src/ diagnostic rather than
silently changing source roots.
The reviewed local dependency graph resolves direct and transitive
trust-lsp.toml path dependencies, includes the root and dependency source
files in compilation and inspection, reports the reviewed LibA, LibB
dependency identities, and produces program.tpi after successful build.
For one unchanged reviewed local dependency graph, two consecutive builds
resolve the same dependency and source order and emit the same image bytes,
apart from the build time in the header and the signature (specification 44
NX-07). This bounded repeatability contract does not establish determinism for
arbitrary registries, symlink graphs, concurrent filesystem mutation, or
cross-platform path representations.
check_program performs the same source discovery and dependency resolution as
build and runs the build's front end and generator for this computer's CPU
without the toolchain, reports the reviewed root source and dependency
identity, and does not create or replace program.tpi.
Dependency resolution rejects a missing declared path, a cycle in the local dependency graph, and a requested package version that disagrees with the dependency manifest. The reviewed diagnostics respectively retain the missing dependency name, identify a cyclic dependency, and retain the requested version. Rejection returns no successful build report.
For the reviewed root project, a structure type declared in one source, a global of that type declared in a second source, and a program accessing the global structure field in a third source compile together and produce the bundle artifact while retaining all three source identities.
These focused builder rules do not prove arbitrary dependency ordering, symlink or concurrent-filesystem behavior, cleanup after partial writes, external registries, bytecode semantic equivalence, or cross-platform path behavior.
Namespace-qualified multifile runtime assembly¶
IEC 61131-3 Ed.3 Section 6.9 and Tables 64-66 define namespace membership,
qualified access, and the USING directive. The runtime source assembler
preserves those identities across the merged source set:
- a direct function member imported by
USINGis callable when the namespace and consuming program come from separate source units; - namespace-qualified programs are registered by their full name and remain executable scan entry points;
- sibling functions, function blocks, classes, and interfaces declared inside one namespace are registered by full name, resolve within that namespace, and execute as one semantic graph; and
- type and member defaults retain their declaring namespace/
USINGcontext, so a qualified or imported namespaced constant resolves identically across source order and from every consuming source; a missing or ambiguous import fails before runtime publication; and - program names are unique under ASCII-case-insensitive comparison across the merged source set. A collision rejects assembly, reports the duplicate condition, and retains the reviewed original spelling in the diagnostic.
These rules do not imply source-order dependence, arbitrary nested-namespace
import, complete diagnostic prose stability, or registration of declarations
that fail semantic validation. Namespace and USING semantics are IEC
behavior; runtime registry identities, scan-entry selection, and the bounded
collision diagnostic are truST integration behavior, not IEC deviations.
Runtime CLI entrypoint, diagnostics, and dispatch contract¶
The trust-runtime entrypoint parses one optional top-level command. A
successful parse dispatches that command exactly once to its corresponding
handler; omitting the command invokes the default runtime workflow. Handler
errors return to the entrypoint, which prints one error and exits non-zero
instead of continuing into another command path.
An invalid top-level command may produce one Did you mean hint before the
ordinary parser error. The hint selects the first registered command with the
smallest case-sensitive edit distance, only when that distance is at most two.
Global flag tokens are not treated as the mistyped command, surrounding
whitespace is ignored for comparison, and inputs outside the threshold receive
no potentially misleading suggestion.
For commands that accept a project folder, deprecated --bundle spellings
remain parser aliases for --project during the compatibility window. After a
successful parse, use of either --bundle <path> or --bundle=<path> emits one
deprecation warning before dispatch. It does not change project creation or
validation semantics.
Ordinary handler failures exit with status 1. When a parsed command enables
--ci, the entrypoint uses the documented CI error classification, including
command-aware fallback codes. Known configuration and permission failures may
append one actionable tip to the original error text; the tip does not replace
or reclassify the underlying error.
For ide serve, the explicit project path, or the current directory when it is
omitted, must be an existing directory. A root runtime.toml is the primary
runtime; otherwise the lexicographically first immediate child containing
runtime.toml is selected. A selected runtime configuration must load
successfully before the web server binds. A workspace without any runtime
configuration uses the standalone config-ui identity so source-only
authoring remains available.
The standalone IDE source registry uses the selected runtime project's normal
recursive, deterministic .st and .pou build-source discovery, while
presenting paths relative to the selected workspace. Its synthetic control
state is local and nonexecuting: control authentication is disabled, debug
execution is not enabled, and discovery and mesh are disabled. The server
starts in standalone-IDE mode only after this state is constructed. These are
truST host-product rules outside IEC 61131-3 and are neither an IEC decision
nor an IEC deviation.
The deprecated config-ui serve alias emits its deprecation warning before
entering this same standalone-IDE validation and startup path; it does not own
a second project-selection or server mode.
Runtime conformance case runner¶
trust-runtime conformance consumes an explicit suite directory or the
conformance directory below the current working directory. The suite root
must exist as a directory. Case discovery is deterministic and fail-closed:
only the registered category directories are accepted; every case directory
requires manifest.toml; the manifest ID and category must match their
directory names; and IDs use
cfm_<category>_<nonempty-lowercase-token-sequence>_<three-digits>.
Manifest source paths are relative, contain no parent/root/prefix component,
and must resolve inside their owning case directory.
The runner supports three closed case kinds:
- A
runtimecase requirescycles > 0. Every nonempty advance, named-input, and direct-input series has exactly one element per cycle. Restart directives target1..=cycles;warm,hot, andfaultuse warm retained-state behavior, whilecoldanddownloaduse cold behavior.skipand_preserve the previous step value. Typed inputs useKIND:VALUEfor BOOL, signed and unsigned integers, bit strings, REAL/LREAL, TIME/LTIME, and STRING, with the declared runtime width enforced. - A
compile_errorcase succeeds only when compilation fails. Successful compilation is a case-execution error and must never be blessed as expected failure output. - A
connector_status_tracecase requires at least one step. Its closed source vocabulary is ADS connection/status, OPC UA client/server, MQTT session, Modbus, EtherCAT, and generic I/O-driver health. Source and state tokens are trimmed, ASCII case-insensitive, and treat hyphens as underscores. Unknown tokens and expected state/health mismatches fail the case.
Cases execute in ascending case-ID order. Runtime artifacts contain the
declared observations for every cycle; connector artifacts contain every
projected step. Each runtime case runs compiled for this computer (specification
44 §14.2): it is built into an image and run cycle by cycle by the kernel; with
--device <port> --target <target>,
the port a serial port or tcp://<host>:<port> of a bridge to one on another
computer, it runs each on the device instead: the case is compiled for the target, as
trust-runtime build --target names it, installed over the device's control
link, and stepped cycle by cycle with cycle.step, the first step restarting it
cold, which loads it, and each later step taking the restarts that the manifest
names before its cycle as one, cold when one of them is. A device takes its debug
surface from the record of the program it has loaded, so the run first installs,
starts and stops an idle program whose record turns the debug surface on; every
case's record turns it on too. A REAL the device
writes through its double is compared as this computer writes the REAL. The
summary names the runtime trust-runtime-device and the actual artifacts go to
reports/actual-device; on this computer they are trust-runtime and
reports/actual. --update-expected writes canonical pretty JSON to the
category's expected-artifact path. Verify mode distinguishes a missing or
unreadable expectation from a readable value mismatch, writes actual mismatch
artifacts, and emits stable reason codes. Suites containing only the six legacy
categories use summary profile/version trust-conformance-v1/1; any expanded
category selects trust-conformance-v2/2. The command writes and prints the
same summary and returns an error whenever any case failed or errored. These
runner and artifact rules are truST product behavior outside IEC 61131-3 and
are neither an IEC decision nor an IEC deviation.
Runtime launcher input, validation, and startup contract¶
trust-runtime play --project <path> is the product convenience entry point.
It creates or completes the selected project when the folder is missing or
lacks runtime.toml or program.tpi; project I/O may instead come from the
system configuration. An existing non-directory project path is rejected
without mutation.
trust-runtime play without --project is the first-run creation exception to
the general optional-project-resolution contract. It first performs standard
bundle detection. A successful detection selects that project and applies the
same completeness rule above. Only the ordinary no-project-found outcome
creates the default project in the current directory. Current-directory access
failures and system-I/O configuration read, parse, or validation failures are
preserved and abort before project mutation.
All non-filesystem launch arguments, including restart mode and simulation time
scale, must be validated before project detection, creation, or any other
project mutation. A rejected option therefore leaves a previously missing
project path missing. trust-runtime run consumes an existing project and does
not provide this creation behavior.
Project-mode startup runs the project's program.tpi and compiles nothing; it
reads the .st and .pou sources beneath the project's selected source root,
case-insensitively by extension, together with sources from resolved local
project dependencies, for the source views. Legacy --config startup
recursively compiles those source types beneath its literal runtime root into a
debug image for this computer, in a temporary folder, with the toolchain of
specification 44 §4.6, installed on the first build that needs it. Source
discovery must not interpret project or directory names as glob syntax, must use
deterministic path ordering, and must propagate directory and file-read
failures. An empty or invalid source set is a startup error. Startup without a
project or legacy configuration is the IDE-shell mode: it compiles one synthetic
empty Main program rooted at __ide_bootstrap__.st as legacy --config
startup compiles its sources, exposes no bundle, and marks the loaded runtime as
an IDE shell instead of claiming that a project was loaded.
Before the first PLC scan, the launcher must successfully complete all startup preparation: bundle-version validation; source and program-image validation; runtime, watchdog, fault, safe-state, telemetry, I/O-driver, and retain configuration; configured ADS and OPC UA client startup; control endpoint and TLS validation; and enabled control, protocol, discovery, mesh, historian, persistence, and web-service initialization. Failure in any of these steps aborts startup; the scan start gate is not opened and the launcher must not report the PLC as running. Writing the endpoint file and the per-user token file is not part of this preparation: a runtime that cannot write them starts and reports a warning (6.8.0a). Warm startup loads and validates the configured retain snapshot before execution. Cold startup does not read retained values. Bundle-version compatibility is checked before watchdog, fault, or other runtime policy is mutated; an unsupported version leaves the prior runtime policy unchanged. A disabled ADS or OPC UA client is a startup no-op and must not construct a transport. Enabling either client without its loaded sidecar configuration is a startup error detected before transport construction or connection-state mutation.
Simulation startup rejects a CLI or project time scale of zero. --simulation
enables simulation without discarding the project's simulation model; a CLI
time scale greater than one enables simulation and overrides the project time
scale. Otherwise the loaded project simulation configuration remains
authoritative. The settings exposed to control and status surfaces must reflect
the effective runtime configuration and this effective simulation selection,
rather than an independent set of launcher defaults.
A simulated launch exposes a warning that names simulation mode, the effective
time scale, and that the mode is not for live hardware. Production mode emits
no simulation warning.
trust-runtime validate --ci validates the same bundle version, TLS, control
authentication and enabled I/O-driver configurations as startup, and admits the
program image as the computer admits it at its start (specification 44 §10.2).
Its versioned JSON success
payload reports only enabled I/O drivers in io_driver and io_drivers, in
configuration order; disabled configuration entries are not reported as active
runtime drivers.
Runtime structured logging accepts error, warn (warning), info,
debug, and trace, case-insensitively. Empty or unknown configured levels are
configuration errors, not aliases for info. Every emitted line is one JSON
object containing a Unix-epoch millisecond timestamp, canonical level, event
name, and event data; events below the configured verbosity are omitted.
SIGINT and SIGTERM request the ordinary bounded runtime stop path so shutdown
and safe-state behavior completes before the launcher emits runtime_exit and
returns success. They end the process whether the resource runs or is Stopped
(specification 34 STATE-08).
Operator-facing I/O provenance labels preserve the selected driver direction.
For Modbus, an input address uses the configured input-register base and an
output address uses the configured output-register base, with the addressed
word offset added to that base. For MQTT, an input address names topic_in and
an output address names topic_out; the opposite-direction topic is never
substituted.
6.8.0 Deprecated workbench command forwarding¶
During the documented compatibility window, deprecated trust-runtime
workbench aliases forward their complete argument vector to trust-dev after
printing the removal-not-before warning. Binary resolution uses an explicit
TRUST_DEV_BIN value first, then an executable regular file named trust-dev
beside the running trust-runtime binary, then normal PATH lookup. A directory
or non-executable sibling must not shadow a usable PATH command.
The forwarding process preserves a normal child exit code. On Unix, when the
child terminates from signal n, the alias exits with the conventional shell
status 128 + n instead of flattening the failure to generic status 1. Spawn
failures remain normal CLI errors with installation guidance.
6.8.0a Default control endpoint and the endpoint file¶
[runtime.control] endpoint is optional. When it is omitted the runtime listens on a default that
works on its platform and never collides with another project:
- On Unix-like systems: the project-local socket
<project>/.trust-runtime/control.sock, in a.trust-runtimedirectory with mode 0700 and the socket with mode 0600. The runtime binds the fallback socket<runtime dir>/trust-<h>.sockinstead when the project-local path exceeds the platform's socket path limit (108 bytes on Linux, 104 on macOS), or when it cannot create the.trust-runtimedirectory or restrict it to its owner, for example in a read-only or foreign project folder. <h>is the first 16 hexadecimal digits of the SHA-256 of the canonical project path, taken over the path's bytes (its UTF-8 text on Windows).- The runtime directory is a per-user directory:
$XDG_RUNTIME_DIRwhen it is set, not empty and passes the checks below, elsetrust-runtime-<uid>in the system temporary directory, where<uid>is the effective user ID. A missing runtime directory is created with mode 0700. It must be a directory and not a symbolic link, belong to the user, and have no group or other permission bit set. A$XDG_RUNTIME_DIRthat fails a check is not used and does not stop the runtime: some systems leave it open to the user's group (a Raspberry Pi 5 had/run/user/1000with mode 0770, where the XDG Base Directory Specification requires 0700), and a shell started withsukeeps the other user's. Whentrust-runtime-<uid>fails a check too, or when the fallback path also exceeds the socket path limit, the default cannot be used and an explicitendpointis needed. - On Windows:
tcp://127.0.0.1:0, a free loopback port chosen by the operating system. Whenauth_tokenis omitted too, the runtime generates a token for each start, 32 bytes from the operating-system secure random source encoded as unpadded base64url; it never falls back to a predictable value.
After it binds the control endpoint, the runtime writes <project>/.trust-runtime/control.json:
{"schema_version":1,"endpoint":"<bound endpoint>","pid":<process id>}. On Unix-like systems a token
that the runtime generated is added as "auth_token", and the file has mode 0600. On Windows a
generated token is never written into the project folder, because a project folder can be readable
by other local users: it goes into the per-user token file
%LOCALAPPDATA%\trust\runtimes\<h>.json, {"schema_version":1,"endpoint":"<bound endpoint>",
"pid":<process id>,"auth_token":"<token>"}, in the local application data folder that Windows keeps
private to its user, and control.json carries the endpoint without the token. The token file is
written before control.json. Every file is written to a temporary name and renamed, so a reader
never sees half of it. The runtime removes its files when its process ends cleanly: after SIGINT
or SIGTERM on Unix-like systems, after Ctrl+C on Windows, and after shutdown with exit
(ctl shutdown --exit). A plain shutdown stops the program and keeps the process, its control
endpoint and so its files (specification 34, STATE-08). A process that stops a runtime without a
clean stop, such as the config UI when it stops or restarts its child, removes the endpoint file
and the token file that name the stopped process.
The runtime always starts when only its endpoint file or token file cannot be written. A tooling file never stops a PLC program (specification 34, ARCH-07: the controller operates independently of the development computer). The runtime then keeps running and:
- logs the warning
control_endpoint_file_unavailablewith the bound endpoint and the reason; - records the runtime event
control_endpoint_file_unavailable(events.tail) withendpoint,errorandtime_ns; - reports
endpoint_fileinstatusas{"state":"unavailable","error":"<reason>"}. The state ispublishedwhen the file was written andnot_usedwhen the runtime runs without a project folder, witherrornull.
Clients then find the runtime through its configured endpoint, through the fallback socket on Unix-like systems (see the client default below), or through the endpoint that the warning names.
A client (ctl, ui, the debug adapter's attach, trust-dev, the VS Code extension and Control
Studio's command line trust-control-studio) resolves a project's endpoint in this order:
- an explicit endpoint;
- the endpoint file;
- the configured
[runtime.control] endpoint; - the platform default.
An endpoint file that exists but cannot be read, for example because it belongs to another user, is
skipped with its reason, and resolution continues with the configured endpoint and then the
platform default. A file that does not parse, has another schema_version, or names an endpoint
that does not parse is an error and is not skipped. Without an endpoint file, a client on a
Unix-like system uses the project-local socket, or the fallback socket when only the fallback
socket exists. The Windows default has a port only while the runtime runs, so without the endpoint
file the client reports that the runtime is not running, or that the endpoint file cannot be read
when one exists.
A client that connects to the endpoint from the endpoint file, or to the platform default, and finds nothing listening there reports that the runtime is not running and does not fall back to another endpoint. Nothing listening means that the connection is refused or times out, or that the socket does not exist; these endpoints are loopback ports and local sockets, where a listening runtime answers at once. Any other connection failure, such as a socket that belongs to another user, is reported as it is. A failure message names a skipped endpoint file and its reason.
Credentials keep the precedence of 6.8.1, with the endpoint file's token between
TRUST_CTL_TOKEN and the configured auth_token. On Windows the endpoint file's token is the
per-user token file's token, taken only when that file names the same process and endpoint as
control.json. The ui console resolves its target again before every reconnection, so it
follows a runtime that restarted on another port or with another token.
The VS Code extension, and Control Studio's command line when --endpoint is not given
(specification 35, §0.1b), ask trust-runtime for the resolved endpoint and do not read the files
themselves. The extension asks at each connection attempt to the workspace's runtime: attach, the
online runtime target, Start in online mode, Live Values status and Devices & Connections. An
endpoint named in its
runtime.controlEndpoint setting is used as it is. trust-runtime ctl --project <dir> endpoint
--json prints {"endpoint":"<endpoint>","auth_token":<token or null>,"source":
"endpoint_file"|"configured"|"default","endpoint_file_skipped":<reason or null>}. It reports
every failure as {"error":{"code":"<code>","message":"<text>"}} with a non-zero exit, and the
extension shows the message. The codes are stable:
| Code | Meaning |
|---|---|
no_project |
--project is missing, or the project folder does not exist |
invalid_argument |
--endpoint was given; the command reports the project's own endpoint |
invalid_config |
the project's runtime.toml cannot be loaded, or its endpoint does not parse |
endpoint_file_invalid |
the endpoint file does not parse, has another schema version, or names an endpoint that does not parse |
endpoint_file_unreadable |
the endpoint file cannot be read and nothing else names an endpoint (the Windows default) |
not_running |
the Windows default has no endpoint file |
default_unavailable |
the platform default cannot be used: the runtime directory is not private, or the fallback socket path does not fit |
Debug output never shows a token: the types that carry one print <redacted> in its place.
The project templates (trust-runtime new, trust-runtime setup and the web setup) and the
shipped examples omit endpoint and auth_token, so every platform uses its default and no example
commits a token. An example keeps an explicit Unix socket only where its own instructions name that
socket (its trust-lsp.toml inline values, its README or its start script); such an example runs on
Unix-like systems only. Managed-fleet scaffolding keeps its explicit loopback TCP endpoints (section
6.8.3). The web IDE settings panel offers no endpoint by default: an empty Control Endpoint means
the platform default, and emptying the field or Reset removes [runtime.control] endpoint, edited by
the runtime, instead of writing a socket path. An empty control or mesh token field, or Reset,
likewise removes [runtime.control] auth_token or [runtime.mesh] auth_token, because the runtime
refuses an empty token.
Every command that creates a project lists /.trust-runtime/ in the project's .gitignore,
because the endpoint file can hold a token. The commands are trust-runtime new, trust-runtime
setup and its wizard, the web setup, a config UI workspace runtime and a web IDE project. A missing
.gitignore is written. An existing .gitignore is kept, also under new --force, and gains the
line when no line ignores the folder yet.
When config.set replaces or clears control.auth_token while the runtime runs, the runtime
withdraws the token it published, so no client is offered a token that no longer works. On
Unix-like systems it rewrites the endpoint file without auth_token; on Windows it removes the
per-user token file. Only files that name the runtime's own process are touched. A file that is
missing, cannot be read or parsed, or names another process is left alone and is no error. A
request that is refused after the token was withdrawn, for example by a full scheduler queue,
puts the token back.
6.8.1 Scriptable control client¶
trust-runtime ctl exchanges one newline-delimited JSON request and response
with the selected runtime control endpoint. --endpoint overrides the endpoint
from the project runtime configuration. Credential resolution is independent
of endpoint resolution and uses this precedence: explicit --token, then
TRUST_CTL_TOKEN, then the token in the project's endpoint file (6.8.0a), then
runtime.control.auth_token from --project. The
project token remains eligible when --endpoint overrides only the endpoint;
an explicit or environment token does not require loading the project merely
to resolve credentials. A serial:// endpoint and a tcp:// endpoint on another
host (below) reach a chip or another computer, not the project's runtime on this
computer, so the project's endpoint file, which names that runtime and the token
it generated, is not read for them: their token is the explicit --token, then
TRUST_CTL_TOKEN, then runtime.control.auth_token from --project. A
project's own endpoint, from its endpoint file, its runtime.toml or the
platform default (6.8.0a), is a runtime's socket on this computer.
Each CLI action maps to one control request with numeric id = 1, its
registered control operation name in type, the resolved token in auth, and
only the parameters declared by that action. Before it, ctl reads the
runtime's truST version with status as request id = 0, with the same auth
(specification 34 TARGET-05): to a runtime's socket on this computer over a
connection of its own, and over a serial endpoint or a tcp:// endpoint on
another host over the connection that the action's request then takes, where a
reply to it that the runtime holds whole is read with transfer.get requests of
id = 0 as well. When the versions differ, ctl writes the update that
TARGET-05 names to its error output; whatever the answer, it then sends the
action's request. The client accepts exactly one
JSON response line. An empty or malformed response is a transport/protocol
failure. The response must end with a newline and contain the same non-negative
integer id as its request; a missing, noninteger, or mismatched response ID is
a protocol failure. A response with ok = false, or without a Boolean ok, is
a command failure and must produce a non-zero process exit rather than being
printed as a successful scripted result. A TCP connection attempt is bounded to
500 milliseconds;
after a TCP or Unix connection is established, reads and writes are bounded to
750 milliseconds, and a response line may not exceed 1 MiB.
--endpoint takes tcp://host:port, unix:///absolute/path or serial://<port>. A tcp://
endpoint on another host, such as a chip's control connection over its network (specification 34
ARCH-12), tcp://192.168.1.50:9901, is reached through the shared control client as a serial
endpoint is, with the serial endpoint's frame, long replies and waits below, and a connection
attempt of at most 5 seconds; a loopback tcp:// endpoint and unix:// are a runtime's sockets on
this computer, with the waits above. The
last reaches a chip over its USB serial port: serial:///dev/ttyACM0 on Linux,
serial:///dev/cu.usbmodem1101 on macOS and serial://COM5 on Windows. The text after
serial://, trimmed, is the port name; it is not empty, and on Linux and macOS it is an
absolute path. ctl, ads server status, ads server symbols, ads server doctor and
discover take all three forms. Over a serial endpoint the same requests and responses
travel as over a socket (specification 34, PORT-05, which also states the client's rules),
with these differences. The command opens the port without resetting the chip. It refuses a
request longer than the link's frame of 8,192 bytes before it sends it, with invalid
request: request longer than 8192 bytes. A response longer than the frame comes as a reply
head, and the command reads it whole with transfer.get, so the response that ctl prints
is the one a computer's runtime writes; the 1 MiB limit of a response line applies to it. It
waits at most 5 seconds for a response, where a socket gets 750 milliseconds. A port that
went away, because the chip reset, lost power or was unplugged, ends the command with a
message that says so. ctl also takes --project, --endpoint and --token after the
action, so ctl install --endpoint serial:///dev/ttyACM0 program.trsta and ctl --endpoint
serial:///dev/ttyACM0 install program.trsta are the same command.
ctl install <container> [--restart cold|warm] installs a program on a chip and on a
computer alike (specification 34, §11.2 and PORT-05). <container> is a file that holds a
TRSTA002 container. The command sends transfer.begin {bytes, sha256} with the file's
length and SHA-256; one transfer.part {transfer, offset, data, crc32} for each part of the
part_bytes that begin's reply names (4,096 bytes, and the last part is the rest), in order,
with the part as standard base64 and its CRC-32 as 8 hexadecimal digits, each checked against
the next_offset of its reply; and program.install {transfer}. A part whose reply does not
arrive in time is sent again, at most twice: the runtime acknowledges the repeat of a part it
has received (specification 34, PORT-05). The reply to program.install is awaited at most
120 seconds on every endpoint, because the runtime writes the program before it replies (a
chip one erase unit per loop pass, specification 34 §5.1); the other replies are awaited as
above. The command prints the identity that the reply gives, on one line: installed
sequence=<n> program_sha256=<64 hex digits> bytes=<n>. When a step after transfer.begin
fails, the command sends transfer.abort {transfer} so that no upload stays open on the
runtime, ignores a failure of that abort, and exits non-zero with the text of the first
failure: the runtime's own text for a refusal, such as resource_not_running from
program.install while the program runs (the program is stopped first with ctl shutdown,
specification 34 STATE-08). With --restart the command then sends restart {mode} and
status until state is running, for at most 10 seconds, and prints restarted
mode=<mode> state=running; a faulted state ends the wait at once and fails with the
fault. Without --restart the installed program does not start.
ctl firmware <image> [--wait <seconds>] updates a chip's truST firmware (specification 34
TARGET-04; the firmware update contract above). It sends capabilities and status first and
fails, before anything is uploaded, with this runtime keeps no firmware slots: truST on a computer
is updated by installing truST on that computer when firmware is null, and with the runtime's
program is <state>: stop it first (trust-runtime ctl shutdown), then update its firmware unless the
state is stopped. It uploads the image as ctl install uploads a container, with "for":
"firmware", and sends firmware.install, whose reply it awaits at most 60 seconds, and prints
installed version=<v> slot=<s>; the runtime restarts into it (it ran <v> from slot <s>). It then
connects again every second, at most --wait seconds (60 by default), until capabilities
answers: when the chip runs the installed version from the installed slot and confirmed is true
it prints running version=<v> slot=<s> confirmed; when rolled_back names the installed version it
fails with the runtime rolled the update back: truST <v> did not finish its boot, and the runtime
runs truST <v> from slot <s> again. trust-runtime firmware install --chip <chip> [--port <port>]
<folder> installs truST on a chip that does not run it yet, through the chip's own boot loader
(specification 34 TARGET-04).
config.set values trim surrounding whitespace and decode case-insensitive
null, true, and false, followed by an in-range signed 64-bit decimal
integer; every other value remains the trimmed string. Human-oriented rendering
happens only after ok = true. Status renders
state=<state> fault=<fault> rt_profile=<profile> rt_active=<bool>, defaulting
an absent or JSON-null fault to none, and absent realtime fields to
disabled and false. A non-null fault must be a string. Health renders
ok=<bool>. An empty task list renders tasks=0; otherwise each task renders
one line containing its name, min/average/max/last milliseconds to three
decimal places, and overrun count.
For status, health, and task summaries, a missing or wrong-typed required result
field is a command failure; it is not rendered as raw JSON or replaced with an
invented value.
ctl events and ctl faults send events.tail and faults. An optional
--limit <n> is sent as limit; without it the request carries no parameters,
so the operation's default of 50 applies. Both print the raw response line, so a
rejected write's write_rejected event is visible from the command line.
ctl shutdown sends shutdown without parameters: it stops the program and
leaves the runtime running in the Stopped state, and ctl restart cold starts
the program again (specification 34 STATE-08). ctl shutdown --exit sends
shutdown with {"exit": true} and so also ends the runtime's process.
A chip answers program.install after its whole commit and answers no other
request meanwhile (specification 34 PORT-05 and §11.2), so a reply that takes
longer than 750 milliseconds is late, not lost. A client that sends
program.install reads its reply for up to T seconds, for a container of L
bytes:
T = 4 + 0.4 × ⌈L / 4096⌉ + 0.01 × ⌈L / 256⌉
The first term is the longest the pass that reads the request may take: the
chip's watchdog interval of 4,000 milliseconds (specification 34 Appendix D.2),
which the container's checks must finish within. The second is the erase of
each 4 KiB unit the container takes, at the up to 0.4 seconds of Appendix D.2.
The third is the programming and the read-back of each 256-byte step of the
commit, two steps at the few milliseconds that specification 34 §11.2 gives,
counted as 5 milliseconds each. A container of 100 KiB (102,400 bytes) gives
4 + 10 + 4 = 18 seconds, and one that fills a whole 2,060 KiB slot of the
ESP32-C6 (2,109,440 bytes: 515 units and 8,240 steps) gives 4 + 206 + 82.4,
about 292 seconds. The bound is the same on
every runtime, because a client sends the same request to each: a computer
commits with no erase or program step and replies sooner, and the bound is then
only a limit. A chip whose resource is Stopped writes an upload into its program store as its
parts arrive (specification 34 §5.1), so it answers transfer.begin after one erase and each
transfer.part after the erase, the program and the read-back of the part. A client reads those
two replies for up to 5 seconds on every runtime: the pass that reads the request (4 seconds),
one erase (0.4 seconds) and the sixteen program and sixteen read-back steps of a 4,096-byte part
(0.16 seconds). Every other request keeps the 750 millisecond read bound,
the other transfer operations included, with one exception: a chip answers a restart that makes it
load a newer container (specification 34 STATE-08) with restart queued at once, and then serves no
request until the load ends, because it reads, checks, prepares and constructs the container in one
step of its control loop. The first request after such a restart waits for the whole load, which the
bench has not measured yet; a client that must not time out reads that reply with the bound T of
the program.install of the same container, which covers the load: the load does the checks of
that install once, where the install did them twice, and writes nothing.
Stepped scans (cycle.step)¶
cycle.step runs one scan of the stopped program on request: at a logical time the client gives,
with the values the client gives written before it, and the values the client names read after it.
Every runtime answers it alike, a computer and a chip (specification 34 STATE-08: no request of one
platform alone). It is how the conformance corpus runs on a device (specification 44 §14.2), and a
test or a simulator may use it too (owner, 23 October 2026).
- It runs only while the resource is Stopped. Any other state refuses it as
program.installis refused (resource_not_running, with the state), and a device without an installed program refuses it with the reason. The step starts no task: the resource is Stopped after it. - It needs the role that writes values (
Engineer) and the debug surface (runtime.control.debug_enabled), assetdoes; without the debug surface it is refused as every debug request is. time_ns(required): the logical time of the scan, a whole number of nanoseconds. The stepped scans have a logical time of their own, which goes on across their restarts, as the conformance runner's time does: no step's time is before the previous step's (time before the last step).restart(optional):"cold"or"warm", a restart of the program before the scan, at the time of the previous step, without starting its tasks. A cold restart starts the installed program cold: a program installed since the program was loaded (program.installwhile Stopped) is first loaded in its place, asrestartloads it (specification 34 STATE-08), and the stepped time then starts at zero. A warm restart restarts the program in place from the values it holds, as an automatic restart after a fault restarts it: the retained values keep what the scans left them, and no checkpoint is read. The first step after the resource stopped must restart the program; one withoutrestartis refused withrestart required.inputs(optional): an object of a name and a typed value text each ("BOOL:true","INT:7", as the conformance manifests write them), written before the scan, in the order of the names, into the variable the name names: a global by its name, a qualified path by its full name, and a program variable by its name alone when exactly one program declares it.direct_inputs(optional): an object of a process-image address and a typed value text each, written into the process image before the scan, in the order of the addresses.watch(optional): the names read after the scan;watch_direct(optional): the process-image addresses read after the scan.run(optional): the name of a program instance whose body runs once in place of the scheduler's cycle, outside the task schedule, as specification 22 runs a test program: the inputs are not sampled and the outputs not published. A name that names no program instance refuses the step before its scan, with the name. Its restart, inputs, watches, error and time are those of any step; this is how a project's tests run on a device (specification 44 §14.2).
The scan is one cycle of the scheduler at time_ns (specification 10 §4.3): the tasks due then run
in priority order, then the programs without a task. The drivers are neither sampled nor driven and
the outputs are published to the output image only; no retained value is captured or saved, no
forced value or queued write applies, and no metric, event or task statistic counts the scan. The
watchdog bounds the scan as it bounds every scan. The result is
{"time_ns": t, "values": {...}, "direct": {...}, "errors": [...]}: each value as a conformance
artifact writes it ({"type": "INT", "value": 7}, a duration in nanos, a date in ticks), and in
errors the text of the error the scan ended with, none for a scan that completed. A fault of a
stepped scan is reported and not latched: no safe state is applied, the resource stays Stopped, and
the next step runs the next scan, as the conformance runner's kernel continues. A name that names no
variable, or that names one of several programs, refuses the step before its scan, with the name (a
step that loads a program finds its names in the loaded program); a value that its variable or
address does not take ends the step before the scan, and the values written before it stay written.
Runtime control request, job, and state-surface contract¶
Each newline-delimited control request is read into one request and dispatched
once. The runtime writes each reply line with its newline in one write, and
every truST client of the control endpoint writes each request line so
(trust-runtime ctl, the console trust-runtime ui, the fleet status request,
trust-dev ctl and the debug adapter's remote attach): a
newline written on its own is held by the TCP stack until the peer acknowledges
the line before it (Nagle's algorithm meeting delayed acknowledgements), which
cost 41 to 44 ms a request on Linux loopback (4 November 2026), and Windows
delays an acknowledgement up to 200 ms. A line is read as a JSON value and the value as the request envelope: a
line that is not JSON gets invalid request: <error> with id 0, no audit
record, no dispatch and no mutation, and JSON that is not a request gets
invalid request: <error> with id 0 and an audit record of type invalid, each
error being the one of that reading. Every runtime first reads a line straight
into the envelope's members, without building a JSON value of the whole line,
and reads a line that this first reading does not take as above, so that every
line gets the same result; the first reading takes an object with each of the
envelope's members at most once and reads every other member as a JSON value
that it drops (on the ESP32-C6 the first reading takes 0.24 to 0.33 ms of a
request where building the value took 0.31 to 0.39 ms, 23 October 2026). A
request type absent from the registered operation set returns the stable error
unsupported request; every registered type routes to its owning handler.
Missing or malformed required parameters return a parameter error rather than
unsupported request. A routing behavior lock proves only that the registered
handler boundary was reached; it is not evidence that every operation succeeded
or returned its complete semantic payload.
Asynchronous operations return a nonempty job ID. Job status is running,
complete, or failed; an unknown ID returns a stable machine-readable
not-found error. Terminal success retains its report and terminal failure
retains its reason. Work that cannot run because a required build feature is
absent terminates as failed rather than remaining indefinitely running. ADS
client and server Doctor use this same job contract. Synchronous and live ADS
operations that require ads-wire fail explicitly when it is unavailable and
must not synthesize discovery, Doctor, route, or import results.
The status and configuration surfaces project one effective runtime state:
status.execution_backend,status.execution_backend_source,status.metrics.execution_backend, and the correspondingconfig.getvalues agree. The backend isnativeon every runtime; the source isconfigwhenruntime.execution_backendnames one anddefaultotherwise.- Status preserves resource identity, control and simulation mode, cycle and
profiling metrics, realtime requested and observed state, warnings, errors,
and every I/O-driver health entry. The default realtime projection is
disabled with scheduler
otherin both status andconfig.get. - Status reports in
metrics.scan_allocationsthe allocator operations of the thread that runs the scans since the program started, each allocation, reallocation and free once (specification 34, "The allocation count"):engine, those the engine made running the program, andservices, those the services made in the calls the scan makes into them and in the runtime's cycle work around the scan. The engine makes none (specification 34, ARCH-05). A runtime that does not count them reportsnull, never zero. - Status reports in
metrics.program_memorythe memory figure of the program that runs (specification 34 MEM-12), in bytes:bytes, the whole, which admission compares with the memory the device has for programs, and its partsprogram,persistent_block,activation_stack,operand_stack,engineandretain_capture. The capture of the retained values is 0 when the runtime keeps none, andbytesis the sum of the parts. The figure is the image's memory figure (specification 44 §10.4): its areas aspersistent_block, its stack bound asactivation_stackand the kernel's bookkeeping asengine, withprogramandoperand_stack0, since a compiled program has no prepared program and no operand stack; it follows the program through a restart, a load in place and an online change. A runtime without a program reportsnull. - Status reports the truST version in
version, the platform the runtime runs on inplatformas<os>-<arch>on a computer, for examplelinux-x86_64orwindows-x86_64, and as the board name on a device, for examplestm32u5a9oresp32c6, and its program-control posture inprogram_control:keeps_controlwhen the runtime still answers aftershutdownand can be started again withrestart(specification 34, STATE-08), which a chip does and trust-runtime on a computer does, andprocesswhenshutdownends the runtime's process, as an earlier trust-runtime did. It reports the runtime's stable runtime ID inruntime_id: the same value the runtime announces by mDNS and keeps across restarts, generated once with a random part and kept in the project's.trust-runtime/runtime_id(the runtime discovery rules below, and decision O36). A state without an installed project (the VS Code simulator,trust-runtime ide servewithout a project) reports its resource name and writes no file. A device without an operating system reports its chip name and USB serial number, for exampleesp32c6-20:6E:F1:17:04:C0: it needs no stored file, stays the same across resets and differs between two chips of one model. - Status reports in
signaturehow the runtime admits program images by their signature (specification 44 §5.11):nonewhile its list of trusted keys is empty, so that it checks no signature and admits signed and unsigned images alike, andtrustedwhile it admits only images a key of its list signed. A computer whosedevice.tomlcannot be read admits no image and reportstrusted. - Status reports
identifying, a Boolean member of the result next toprogram_control, on every runtime:truefrom an acceptedidentifyrequest until its duration has passed or it ends, andfalseotherwise, also on a runtime that has no identify output (see Identify in "One search, device identity, and presence"). - Aggregate health is false when any I/O driver is faulted and retains the faulted driver's stable error detail; runtime readiness alone cannot hide that fault.
config.setuses a closed key, type, and value contract, validates cross-field security requirements, and rejects invalid input atomically.runtime.execution_backendis startup-only and cannot be switched through live control. This atomicity is validation-before-logical-mutation; crash- atomic filesystem replacement is separate verification depth.historian.queryandhistorian.alertsreturn anitemsarray, honor the requested limit, and return matching persisted samples or alerts when they exist.
Status, health, and task-stat projection is fail-closed and deterministic.
status returns an error when runtime settings, control mode, I/O health,
metrics, realtime-status state, or the event store's capacity is unavailable; it
must not substitute production mode, a default backend, an empty driver list, zero
metrics, or a default realtime posture for an internal read failure.
tasks.stats likewise returns an error when metrics are unavailable. health
returns an error when I/O health or realtime status is unavailable.
The aggregate health.ok value is true only when the resource is running,
ready, or paused, has no resource fault, has no faulted I/O driver, and has no
realtime error. A degraded I/O driver or realtime warning remains visible but
does not alone make the aggregate false. Every I/O entry contains its exact
name and one of ok, degraded, or faulted; only degraded and faulted
entries contain their stable error detail. Status preserves I/O registration
order. Task statistics are sorted lexically by task name, independent of map
iteration order. Profiling contributors retain contribution-descending order
with their established lexical tie-break.
events.tail and faults accept either no parameters or a strict object
containing only optional limit. The default is 50 and the accepted range is
1 through 1,000 inclusive. Null, a non-object, an unknown key, a non-integer,
a Boolean, or an out-of-range value rejects instead of silently selecting a
default. Both operations return newest entries first and fail when the event
store is unavailable. events.tail applies the limit to all events. faults
first selects only RuntimeEvent::Fault entries and then applies the limit, so
intervening non-fault events cannot reduce the requested number of faults.
SafeStateFailed remains its own event type and is not silently relabeled as
the originating resource fault. On a link that bounds its frames, such as a
chip's USB link, a reply longer than the frame is answered as specification 34
PORT-05 says; a reply that fits is the same bytes as on any other link.
The event store keeps the newest runtime.events.capacity events, 1,000 when
the project sets none on a computer; a device without an operating system takes
its default and its maximum from the device's chip file (specification 34,
TARGET-03 and Appendix H), and admission refuses a project that sets more than
the maximum. The setting is an integer from 1 through 1,000,000 inclusive;
another value or type is a configuration error, as for every other
runtime.toml setting. A newer event displaces the oldest, and events.tail
and faults answer at most what
the store holds within their own request limits above. status reports the
store's capacity as event_capacity. The store keeps every runtime event but
the four that every scan makes, cycle_start, cycle_end, task_start and
task_end: at a scan every few milliseconds they would displace every other
event within a few scans, and a scan's and a task's timing is in tasks.stats
and the status metrics, as an industrial controller keeps its cycle times in
statistics and its diagnostic buffer for the events that matter (the owner, 22
October 2026: "I trust you, do what's most professional for a plc runtime").
Overruns, faults, refused writes, online-change and retained-value events and
the control events stay in the store. A debug control delivers the four, with
every other event, to the callers that drain it or listen to it. A debug control with no event sender
attached buffers events for callers that drain them directly, such as embedders
and the debugger. That buffer keeps the newest 1,024 events, drops the oldest
beyond that bound and counts every dropped event, so the caller can tell that it
missed events (specification 34 §11.1).
Historian query parameters are strict objects. historian.query permits only
variable, since_ms, and limit; historian.alerts permits only limit.
An omitted object uses limits 250 and 200 respectively. Query limit is in the
range 1 through 5,000 inclusive and alert limit is in the range 1 through
1,000 inclusive. since_ms is an exactly representable nonnegative integer.
When present, variable is a string that is non-empty after trimming; the
trimmed exact path is used for matching. Unknown fields, null, wrong types,
fractional values, empty variable names, and out-of-range limits reject with
invalid params rather than relying on the historian's defensive internal
clamp. A disabled historian still returns historian disabled and does not
claim that an invalid query was executed.
config.set helper validation is exact and side-effect-free. Boolean fields
accept only JSON booleans. Positive-integer fields accept an exactly
representable signed 64-bit JSON integer in the range 1 through i64::MAX;
zero, negative, fractional, Boolean, string, container, and larger unsigned
values reject. Non-empty strings trim Unicode whitespace and reject all-empty
input.
String arrays preserve order, trim every entry, and reject a non-array, non-string entry, or empty trimmed entry. String maps require an object, trim both keys and values, reject empty trimmed keys or values and non-string values, and emit deterministic key order. Empty arrays and objects are valid where the owning setting permits an empty collection.
Runtime-cloud WAN rules are a strict array of objects containing exactly
action and target; link-preference rules contain exactly source, target,
and transport. Missing, null, wrong-typed, empty, or unknown fields reject
with the owning key and zero-based entry index. All strings are trimmed.
Transport uses the closed runtime-cloud vocabulary and canonicalizes the
Modbus hyphen/underscore alias. Rule order is preserved.
Validation errors have the stable prefix
invalid config value for '<key>':. They may identify an entry or unknown
field but do not serialize sibling values. Helper rejection performs no state
mutation and cannot append an item to either the updated or restart-required
result.
Live config.set requires an object; an absent payload and a non-object payload
are distinct errors. An empty object is a successful no-op with empty
updated and restart_required arrays. The handler stages the complete
effective settings, control token, control mode, and debug-enabled state,
validates every key and all final-state cross-field requirements, and only then
commits any of them. An unknown key, startup-only key, type/value error,
cross-field error, unavailable lock, or scheduler-update rejection leaves all
four state domains unchanged.
The final state may use web.auth = token only with a non-empty final
control.auth_token. This applies both when token mode is selected and when an
existing token is cleared. A TCP control endpoint that requires authentication
never accepts a null token. Supplying a new token and selecting token web auth
in one object is order-independent. Token and mesh-token values are trimmed,
never returned, and never included in success or error text.
updated contains each accepted request key exactly once in canonical lexical
order. restart_required is the lexical subset whose effective runtime service
cannot change in place: retain mode; web, discovery, mesh, runtime-cloud, and
control-mode settings. Log level, watchdog settings, fault policy, retain save
interval, control token, and debug-enabled state are live updates and do not
claim restart. A rejected request returns neither list.
config.get returns the complete effective key projection without credential
values. Control and mesh credentials expose only presence; control additionally
exposes length when present and null length when absent. Enum-like values use
the same canonical lowercase or snake-case spelling accepted by configuration:
watchdog actions, fault policy, retain mode, control mode, mesh role,
runtime-cloud profile/transport, realtime scheduler, and execution backend.
Derived startup/backend-source, simulation, observability, and credential
presence fields are read-only observations rather than implicit authorization
for config.set.
log.level takes the level names of the configuration, error, warn or
warning, info, debug and trace, trimmed and in any ASCII case, and
another name rejects with invalid config value for 'log.level': must be error,
warn, warning, info, debug or trace; config.get reports the level in its
canonical spelling. A computer writes its log lines by the new level at once.
A device without an operating system answers both requests from the deployment
record of its installed program (specification 34 §5.1.1, section 7), as
config.set changed it, with the keys every runtime has: log.level, the
watchdog.* keys, resource.cycle_interval_ms, runtime.execution_backend
and its source, fault.policy, retain.mode, retain.save_interval_ms and the
control.* keys, in the spellings and with the values above (specification 34
TARGET-07). It runs none of a computer's own services, so it has none of their
keys (web.*, discovery.*, mesh.*, runtime_cloud.*, realtime.*,
opcua.*, simulation.*, observability.* and hmi.*), and config.set
rejects one as an unknown key. It has no log output beside its event store,
which keeps every event whatever the level, as a computer's store does. It
commits a request that passes every check before it writes the reply, and the
program takes the watchdog, the fault policy and the retain save interval at
its next boundary, where a computer's scan thread takes them, also while it is
paused or faulted. A program that has ended takes none of them, so a request
that changes one is rejected with the stable refusal that names the resource
state, as a computer's closed scheduler rejects it. Its hardware watchdog's
interval follows watchdog.timeout_ms (specification 34 TIME-04), so a
timeout whose interval that watchdog does not take is rejected with invalid
config value for 'watchdog.timeout_ms': the device's hardware watchdog does not
take the interval this timeout needs. A control token that config.set gives
it is kept in memory, as a computer keeps one, and required from then on, until
the next reset, when the device settings' token ([control] token, specification
34 TARGET-02) holds again. A device that serves its control connection over a
network requires a token, as a computer's TCP endpoint does, so config.set
rejects a null control.auth_token there with auth token required for tcp
endpoints. A device without an installed program rejects both requests with
no installed program: <reason>, as it rejects restart.
Runtime execution-backend report contract¶
Every runtime runs the program compiled into a program image (specification 44
§8.8), so runtime.execution_backend selects nothing. The configuration parser
trims surrounding whitespace and matches ASCII case-insensitively: vm and
native load, as a project written before may name them, and make the
reported source config; an absent key makes it default. An empty value
rejects with runtime.execution_backend must not be empty, and any other value
with an invalid-configuration error that names runtime.execution_backend and
preserves the rejected text. The retired interpreter value rejects with
runtime.execution_backend='interpreter' is no longer supported; remove the key,
every runtime runs the compiled program.
status.execution_backend, status.metrics.execution_backend, config.get's
runtime.execution_backend, the startup log event execution_backend_selected
and the Prometheus metric trust_runtime_execution_backend_info report
native.
A freshly loaded compiled runtime has zero elapsed logical time and zero completed cycles.
This contract does not authorize another production backend, arbitrary source semantics, or a general performance claim.
Runtime historian recording and export contract¶
An enabled historian in all mode records the reviewed global BOOL, integer,
floating-point, and string values with their runtime types. An accepted capture
at time t establishes the sample-interval boundary: a second request before
t + sample_interval_ms returns zero and adds no sample, while a request at or
after that boundary records the current snapshot. Queries for the reviewed
paths return the recorded values in capture order.
In allowlist mode, exact configured names and the reviewed retain.* prefix
are recorded; unmatched globals are absent. A file-backed historian reloads
the reviewed persisted sample when a new service instance opens the same
history path. The Prometheus renderer includes the reviewed runtime uptime and
fault totals together with historian sample and alert totals when the historian
snapshot is supplied.
For the reviewed single-variable above alert, two consecutive samples above
the threshold satisfy debounce_samples = 2 and emit one Triggered event.
The next below-threshold sample emits one Cleared event. A configured file
hook receives exactly one line for each emitted transition, for two lines in
the reviewed trigger/clear sequence.
These cases do not establish arbitrary value conversion, wildcard grammar
beyond the reviewed exact and retain.* forms, concurrent writers, crash
durability, retention limits, arbitrary alert expressions or hook transports,
or the complete Prometheus schema.
Runtime metrics window and profiling contract¶
Runtime profiling starts enabled. While enabled, a recorded call is grouped by
its kind:name identity, increments its call count, and contributes its elapsed
time to the per-cycle average. A profiling snapshot retains the reviewed call
identities and ranks the reviewed program call ahead of the smaller
function-block call by average cycle contribution.
Hosted program-call instrumentation records each invoked program under its canonical configured instance name, including multiple programs in one task and unscheduled background programs. A program invocation that returns an execution fault is still recorded; later programs in the failed cycle are not invoked or counted. Observation does not change the original execution error. The kernel reports the start and the end of each program body as paired, read-only observations (specification 44 §8.7), and every runtime, a chip as a computer, times them on its monotonic clock; a function block instance that a task runs on its own is recorded under the program instance that holds it.
Disabling profiling clears accumulated call entries and top contributors and ignores calls recorded while disabled. Re-enabling profiling starts a clean collection, and a subsequently recorded call appears as the sole reviewed entry. For the reviewed non-wrapped cycle window containing 5, 10, 15, 20, and 25 milliseconds, the snapshot reports five samples, a median of at least 10 milliseconds, a p95 not below the median, and a maximum of 25 milliseconds.
Cycle, task, and call durations are represented in milliseconds as finite
f64 values derived from non-negative monotonic durations. The first sample
sets minimum, maximum, average, and last to the same value. Later samples update
minimum and maximum, set last to the newest value, and compute the arithmetic
mean over every accepted sample. An unrecorded statistics object exposes zero
for every duration field and zero samples/calls. Recording a zero duration is a
real sample, not an absent value.
Cycle percentile reporting uses a rolling window of the most recent 512 cycle
durations. The 513th sample evicts only the oldest sample. Snapshots read the
percentiles from a copy of the retained values held on the stack, reordered by
selection rather than a sort of the whole copy, with no allocation, and do not
mutate collection order or future rollover; on the ESP32-C6 this part of a
snapshot takes 0.5 to 0.8 ms, as the sort did (23 October 2026). With the n
values in ascending order, p50, p95, and p99 are the values at index round((n -
1) * q) for quantile q, rounded half away from zero; quantiles below zero
clamp to zero and above one clamp to one. An empty window reports zero
percentiles, zero maximum, and zero samples. Every non-empty snapshot reports
the exact retained sample count, p50 <= p95 <= p99 <= maximum, and finite
values.
Task identities are exact and independent. A recorded task duration creates or
updates only that task. A positive missed-activation count increments both the
runtime-wide overrun counter and that task's overrun counter with saturating
u64 arithmetic. A zero missed count is a no-op and does not materialize an
otherwise absent task. Fault counts also saturate. Snapshot task rows are
sorted by task name so identical runtime state has deterministic JSON and
control-surface projection regardless of hash-map insertion order.
Call identities are the exact kind:name pair. Each call row reports its own
minimum, maximum, mean, last duration, call count, and total call time divided
by the number of recorded cycles as avgCycleMs. When no cycle has been
recorded, the denominator is one so a recorded call remains visible, while its
cycle percentages are zero because no cycle budget exists. Multiple calls with
the same identity aggregate; different identities never merge.
Top contributors contain at most five call identities, ordered by descending
avgCycleMs and then ascending identity key for ties. cyclePct is
avgCycleMs / cycle.avgMs * 100; lastCyclePct is
call.lastMs / cycle.lastMs * 100. A zero cycle denominator yields zero rather
than NaN or infinity. Percentages are not clamped to 100 because nested or
overlapping instrumentation may legitimately sum beyond one cycle budget.
Setting profiling from enabled to disabled clears all accumulated call rows and contributors. Calls while disabled are ignored. Repeatedly setting the already disabled state remains empty; repeatedly setting the already enabled state preserves current samples. Re-enabling after a disabled interval begins with an empty profile and does not reconstruct ignored calls. Cycle, task, fault, and overrun metrics are independent of the profiling toggle.
A metrics snapshot is observational: it does not reset counters, consume
samples, reorder retained window state, or alter profiling enablement.
Successive snapshots without new records preserve all metric values except
that monotonic uptime may increase. The status's metrics.execution_backend
reports native.
These rules establish the in-memory accounting and snapshot contract. They do not claim wall-clock accuracy, lock-free concurrent collection, instrumentation completeness, a stable observer-overhead budget, or a general execution-time guarantee.
Runtime clock contract¶
clock.set sets the wall clock of a device without a battery-held clock from the computer at the
other end of its control connection (specification 34, TARGET-06). Its one parameter, utc_ms, is
the computer's time in whole milliseconds since 1970-01-01T00:00:00Z; a request without it, with
another parameter or with a value that is no whole number of milliseconds is refused with
invalid params: <reason>. A device sets its clock to it and answers {"utc_ms": <its clock after
the set>, "was_utc_ms": <its clock before the set, null while it was not set>}, so that a tool can
tell a device that kept its time, and by how much it drifted, from one that lost it; from then on
CURRENT_DT reads that clock, and replies and events carry the time (TARGET-06). A computer's operating system holds its clock: trust-runtime on Linux, macOS and
Windows refuses the request with the code clock_not_settable and the message the operating
system holds this runtime's clock, and changes nothing. The minimum role is Engineer, and the
request carries one audit record without operation details.
The tools that open a device's control link (specification 34, PORT-05: trust-runtime
conformance --device, trust-dev test --device, and every tool that reaches a device over that
link) send clock.set with their computer's clock right after opening it. So a device that lost
power has the time again as soon as a computer talks to it, and one that kept it takes the
computer's time, which corrects what its crystal drifted. A refusal, or the unsupported request
of a firmware that has no clock, changes nothing and does not stop the tool.
Runtime device capabilities contract¶
capabilities reports the device descriptor that admission reads (specification 34, COMPAT-02 and
§5.1.1) and, on a device without an operating system, the reset its boot read. It takes no
parameters and changes nothing; its minimum role is Viewer. Like every control operation it
carries one audit record and, being a read of the device's own descriptor, no operation details.
trust-runtime ctl capabilities prints its result as indented JSON. Its result is the same shape
on every runtime, a computer or a device without an operating system:
| Result field | Type | Value |
|---|---|---|
program_versions |
array of strings | The program formats the device runs, each <major>.<minor>: 44.1 where it runs compiled program images (specification 44 §10.1, §10.2), and none on a computer whose CPU runs no image |
scheduling_profiles |
array of strings | The scheduling/I/O profiles the device runs (specification 34 §4.4) |
numeric_contracts |
array of strings | The numeric contracts the device implements (specification 34 §12.1) |
timing_evidence |
string | unqualified, measured-qualified or analytically supported (specification 34 §4.4) |
capabilities |
array of strings | The platform capabilities the device supplies, by name: bit 0, the platform wall clock (specification 34 §5.2), is wall_clock; any other set bit n that the specification does not name is capability_bit_<n> |
maxima |
object | The largest request the device admits for each preparation limit, under that PreparationLimits field's name (for example validation.max_scratch_bytes): an integer where the device bounds the limit, and null (no maximum) where it bounds nothing. A limit at or above usize::MAX, the largest value the platform holds, is no maximum: a chip writes it as u64::MAX, a computer as usize::MAX. |
event_store_default |
integer | The newest events the device's event store keeps when a project sets none |
event_store_max |
integer | The most a project may set for the device's event store |
application_slot_bytes |
integer or null | The bytes of the device's application slot, or null when it has none |
program_memory_bytes |
integer or null | The bytes of memory the device has for a program, which admission compares a program's memory figure with (specification 34 MEM-12, §5.1.1): a chip's measurement at boot, or null where there is no maximum, as on a computer |
last_reset |
object or null | The reset the device's boot read (below), or null |
firmware |
object or null | A chip's firmware slots (specification 34 TARGET-04; the firmware update contract below), or null on a runtime that keeps none, a computer among them |
network |
object or null | A chip's network as it stands (specification 34 ARCH-12; below), or null on a runtime that reports none, a computer among them, and on a chip whose device settings name no network |
heap |
object or null | A chip's heap as it stands (specification 34 MEM-12): capacity_bytes, the bytes its allocator was given at boot; used_bytes, the bytes in use now; and peak_bytes, the most in use at once since the boot; null on a computer. It shows what a program, its drivers and the services took, which program_memory_bytes, measured before any program, cannot |
firmware field |
Type | Value |
|---|---|---|
slot |
string | The slot the firmware runs from: a or b |
version |
string | The running firmware's truST version |
confirmed |
boolean | False while the firmware runs on trial after an update, until its boot is done; true otherwise |
rolled_back |
string or null | The version of a newer firmware in the other slot that the boot loader stopped booting because it did not confirm itself, or null |
network field |
Type | Value |
|---|---|---|
interface |
string | wifi or ethernet |
link |
string | up while the chip has joined its Wi-Fi network or its cable is plugged in, down otherwise |
address |
string or null | The interface's IPv4 address with its prefix length, 192.168.1.50/24, while it has one |
control_port |
integer or null | The TCP port of the control connection over the network, or null while the chip serves none (its device settings set no control token) |
error |
string or null | Why the interface did not start, for example the Wi-Fi radio did not start: ..., or, until the chip joins its network, why its last attempt to join failed, for example joining the Wi-Fi network failed: ... or the Wi-Fi network did not answer within 20 seconds; null otherwise |
driver_memory |
object or null | The memory the network's driver has of its own, where it has some (the ESP32-C6's radio arena, specification 34 D.2): bytes, peak_bytes, the most it has held at once since boot, and failures, the allocations it refused since boot; null otherwise |
time |
object or null | The time server of the device settings (specification 34 TARGET-06): server, offset_ms, how far behind the server the clock was when the last request set it (negative when ahead), null before a request set it, and error, why the last request took no time, null otherwise; null without a time server |
Device settings contract¶
A chip keeps its own settings, device.toml (specification 34 TARGET-02, which defines the keys and
their checks). device.get (Engineer) answers {"settings": <object>, "sequence": <n>}, the settings
as device.toml's sections with network.wifi.password replaced by password_set (true when a
password is set) and control.token by token_set, or {"settings": null, "sequence": null} when
none are stored. device.set
{settings} (Admin) refuses, in this order: missing params; invalid params: settings must be an
object; a settings object that does not read as device.toml (an unknown section or key, a value
of the wrong type), as invalid params: <reason>; a hung resource; a resource that is not Stopped, as
resource_not_running; a setting the chip cannot take, with the key and the reason, for example
network.ethernet: the ESP32-C6 has no Ethernet; and a flash failure. It answers {"status":
"stored", "sequence": <n>, "applies": "boot"}. device.reset (Admin) refuses a hung resource and
one that is not Stopped, and answers {"status": "resetting"}; the chip answers no further request
and resets once the reply is sent and 100 ms have passed. A runtime that keeps no device settings, a
computer among them, answers all three with unsupported request. ctl device-get prints the
settings as device.toml text after the line # device settings, sequence <n>, or no device
settings are stored; ctl device-set <file> reads the device.toml file and sends its tables as
settings, and prints stored sequence=<n>; the settings apply at the device's next boot
(trust-runtime ctl device-reset); ctl device-reset prints resetting.
Firmware update contract¶
A chip is updated over its control connection (specification 34 TARGET-04). While the resource is
Stopped, transfer.begin with "for": "firmware" opens an upload of the chip's firmware image that
the chip writes into the firmware slot it does not run from; for is program (the default) or
firmware, and any other value is refused with invalid params: for must be "program" or
"firmware". On a resource that is not Stopped it is refused as resource_not_running, and on a
runtime without firmware slots, a computer among them, with unsupported request. firmware.install
{transfer} (Admin) refuses, in this order: missing params and a malformed transfer; a hung
resource; a resource that is not Stopped, as resource_not_running; a runtime without firmware
slots, with unsupported request; a transfer that is not an open firmware upload, with the
transfer is not a firmware upload: begin it with "for": "firmware" (an unknown or unfinished one
with the session's own text); the chip's check of the image, with its text, for example the
firmware image's SHA-256 is wrong. The transfer is taken only after the checks before the image's
own, so those refusals leave it open, and program.install of a firmware upload is refused with
the transfer is a firmware upload, which firmware.install installs and leaves it open too. The
reply waits for the commit (the image's first bytes, their read-back and the slot's selection) and
is {"status": "installed", "version": "<version>", "slot": "a"|"b", "restarting": true}. The chip
then answers no further request and resets into the new firmware once the reply is sent.
A computer reports last_reset as null: it starts as a new process. A device without an operating
system reports the reset its boot read once: the record the previous boot left (specification 34,
the fixed-size reset record) or, when it left none, the boot's hardware reset reason. A hardware
watchdog reset runs no code and so leaves no record; its reason (0x07 on the C6, specification 34)
reports watchdog with empty text. A reset the chip makes itself once it has sent the reply to
device.reset or firmware.install leaves a record with the cause requested and empty text. Any
other boot with no record, a power-on, a host (USB) reset or a reason the hardware cannot name,
reports power_on:
last_reset field |
Type | Value |
|---|---|---|
cause |
string | The record's cause: panic, watchdog or requested; without a record, watchdog for a watchdog reset reason and power_on for any other |
text |
string | The record's text, for example a panic location, empty when the boot read no record |
boot_count |
integer | The boot counter the chip keeps, counted for this boot, for every reset whether or not a record exists |
Runtime terminal console contract¶
The optional trust-runtime ui terminal console is an operator-facing
projection of the versioned control protocol. It does not own a second runtime
state model and it does not make a successful control operation out of a
failed, malformed, or unavailable control response. These are truST product
rules outside IEC 61131-3.
Endpoint selection is deterministic. An explicit endpoint is parsed and used
without requiring a bundle. Otherwise the selected bundle supplies the
endpoint and optional configured authentication token. An explicit token has
precedence over the environment and bundle; a nonempty environment token has
precedence over the bundle. Empty token text is absence, not a credential.
Every request is one newline-delimited JSON object and carries the selected
token in auth. The refresh transaction requests, in order, status,
tasks.stats, io.list, events.tail with limit 20, and config.get, using
distinct request IDs 1 through 5. Transport or decoding failure aborts that
refresh; the console retains the last complete snapshot instead of installing
a partial one.
Response projection has closed fallback behavior:
- status requires an object result and a string state; absent optional
counters and timings are zero, the resource is
resource, the fault isnone, the control mode isunknown, and simulation defaults to production at scale one; - task, I/O, and event projections preserve response order and return an empty
list when
resultis absent or not an array; - I/O string values remain unquoted while non-string JSON scalars use their JSON spelling;
- event levels
faultanderrormap to Fault,warnandwarningmap to Warn, and other or absent levels map to Info; - settings require a result object and use the documented runtime defaults. Negative or unrepresentable cycle intervals and simulation scales are rejected as absent and defaulted rather than narrowed.
Console configuration is advisory. An absent, unreadable, or malformed
runtime.toml, an absent console table, and a layout containing no recognized
panel names all select the built-in layout. Recognized layout names are
case-insensitive, retain declared order, and discard unknown entries. A
positive console.refresh_ms overrides the CLI default only when the caller
left that default unchanged; zero and negative refresh values are invalid and
do not create a busy loop.
Prompt editing is UTF-8 safe: the cursor always denotes a character boundary, left/right/backspace operate on one complete Unicode scalar, and no accepted key sequence may panic. Activation positions the cursor at the end of the initial text; deactivation clears transient history navigation. Empty and whitespace-only commands are not retained. History traversal saturates at the oldest entry, advances toward newer entries, and clears the input after the newest entry. Suggestion traversal wraps in both directions, and selecting a suggestion records and executes that exact slash-prefixed command.
Read-only mode accepts only observation and exit interaction. / reports
read-only state without activating a prompt, and q exits. It must not issue
pause, resume, stepping, restart, shutdown, I/O, configuration, or other
mutating requests. Beginner mode exposes only its documented beginner command
set and disables direct debug controls. A confirmation performs an action only
for case-insensitive y or yes; every other response cancels. Escape and
control-C leave a normal inactive prompt without executing the pending
operation.
The cycle sparkline retains at most 120 finite nonnegative samples, rounded to
tenths of a millisecond with zero rendered as the minimum visible bucket.
Absent, NaN, infinite, or negative cycle values do not become fabricated large
samples. Alerts retain at most five newest entries. Event alert deduplication
uses the complete event identity (code, timestamp, level, and message), so a
new occurrence reusing a code is not hidden; informational events do not
produce alerts. Watch evaluation preserves watch-list order and classifies a
missing result as unknown, a protocol error as error: <message>, and a
transport failure as unavailable.
Command and menu selection is deterministic. Command names are the documented
lowercase tokens, optional leading / is accepted, numeric menus are
one-based, and invalid or out-of-range choices retain the current mode with an
explicit error. Navigation wraps over the currently eligible entries. I/O
Read may select any listed point; Set, Force, and Unforce offer output points
only. Boolean values receive the TRUE/FALSE chooser, while other values enter
the explicit value prompt. A local force marker is added or removed only after
the corresponding control response succeeds. unforce all retains markers
whose release failed and reports the failure; it must not claim that all
forces were released after a partial failure.
Settings distinguish live acceptance from durable project mutation. Boolean inputs accept the documented true/false aliases; TOML scalar parsing trims text and preserves Boolean, signed integer, or string type. Nested TOML update creates missing tables but rejects traversing a scalar. A project-backed setting is reported saved only after the candidate TOML validates and the file write succeeds. A control-backed setting is reported saved only after the control response is successful; when both live and durable updates are required, either failure is visible and success is not fabricated. Restart menus open only for an accepted change that actually requires restart.
These unit-level rules prove console parsing and state transitions. They do not replace an interactive terminal smoke test for rendering, terminal restoration, or a live runtime control connection.
The role and transport rules in section 6.9.4 govern these handlers. A pairing token retains its stored role; an unauthenticated network connection receives Viewer authority; and a local mode-0600 Unix socket without configured authentication may receive Admin authority.
Runtime HMI¶
Runtime HMI pages, values, commands, and alarms are specified in
docs/specs/39-hmi-builder.md.
Runtime control audit and debug projection¶
Every accepted or denied control operation attempts to emit its audit record.
If the audit sink is unavailable, the request retains its normal semantic
result and audit identity, and the runtime emits an AuditDropped event; it
must not pretend durable audit succeeded. A request for a compiled-out or
disabled feature returns the stable code feature_disabled and emits a
FeatureDisabled event.
Debug variables and evaluation expose the declared public type name and value, not internal instance IDs. A stale frame, unknown variable reference, unknown evaluation name, or unavailable/poisoned I/O snapshot fails closed with a negative response. These failures do not return an empty successful projection or mutate debug or runtime state.
eval, debug.stack and debug.variables answer from the published debug state described in section
6.13.1: the storage as it stood at the end of the last completed scan, one consistent state and never
half a scan, and never a copy the runtime makes per statement. While a debugger holds the scan they
answer the halted state.
Runtime control I/O and variable mutation contract¶
The attach-mode JSON control surface uses strict parameter objects for debug
mutation. io.write and io.force require exactly string fields address and
value; io.unforce requires exactly string field address. eval requires
exactly string field expr; set requires exactly string fields target and
value; var.force requires exactly string fields target and value; and
var.unforce requires exactly string field target. Missing parameters use
the stable missing params failure. Null, a non-object, a missing field, a
wrong-typed field, or an unknown field rejects as invalid params. Rejection
queues no write and changes no force.
Control I/O addresses use the canonical uppercase %I, %Q, or %M grammar
after trimming surrounding whitespace. A mutation address is concrete:
wildcards reject. Flat addresses must fit the 16,777,216-byte area, including
the complete width of byte, word, double-word, and long-word accesses.
Hierarchical addresses retain every declared path segment and bit coordinate.
The same address eligibility applies to write, force, and release; release
cannot use an address that write or force would reject merely because no new
value is supplied.
io.list returns the cached snapshot directly and fails with
no snapshot available before the first captured scan. io.read returns
{"snapshot": null} for that legitimate pre-scan state. If the snapshot lock
is unavailable, both operations fail with I/O snapshot unavailable; they do
not collapse an internal failure into a pre-scan state. A returned snapshot is
one coherent cached scan, including only the force marks captured with that
scan.
Typed values (the owner, 26 October 2026: "did you run a program, simulate and force values
etc? did you test all workflows?"). Where the destination's declared type is known when the request
arrives, a variable target that the installed program resolves (set, var.force, debug.set of
section 6.13.4) or a process-image address that a variable is bound to (io.write, io.force), the
value text, trimmed, is an IEC 61131-3 literal of that type, read as the language reads it:
- a Boolean:
TRUEorFALSEin any case, or0and1(IEC 61131-3 Ed.3 §6.3.2, Table 5 features 7 and 8); - an integer or a bit string: a decimal number with an optional sign, or a number in base 2, 8 or 16
without one (
2#1010,8#17,16#FF), single underscores between digits not significant (Table 5 features 1, 4 to 6); an integer is admitted to aREALor anLREALthat holds it exactly; - a real number with a decimal point and an optional exponent (
3.5,-1.34E-12,1.0e+6, Table 5 features 2 and 3), read once as the binary floating-point number of the destination's type nearest to its text; - a typed literal
TYPE#literalof an elementary type (INT#-123,INT#16#7FFF,REAL#3.5,BOOL#1,CHAR#16#41, Table 5 feature 9), which a destination of that type or of a wider type of the same kind takes; - a duration
T#,TIME#,LT#orLTIME#(T#5s,t#1h_30m,T#-14ms,LTIME#5m_30s_500ms, §6.3.4, Table 8) in whole nanoseconds; anLTIMEtakes aTIMEliteral and aTIMEnoLTIMEone; - a date, a time of day or a date and time,
D#/DATE#,LD#/LDATE#,TOD#/TIME_OF_DAY#,LTOD#/LTIME_OF_DAY#,DT#/DATE_AND_TIME#andLDT#/LDATE_AND_TIME#(§6.3.5, Table 9), a date that exists and a time of day within the day; aDATE, aTIME_OF_DAYand aDATE_AND_TIMEhold milliseconds, so a finer time is refused for them; - a character string in single quotes for a
STRINGand in double quotes for aWSTRING, with the$combinations of Table 7 and$hhand$hhhhcodes (§6.3.3, Table 6), typedSTRING#'OK',WSTRING#"OK"andWSTRING#'OK'; aCHARtakes one character in single quotes and aWCHARone in double quotes; - an enumeration's value, bare (
Red) or typed (Color#Red, §6.4.4.2, Table 11).
A literal of another type, outside the range of the destination's type or subrange, longer than the
text the destination holds, or no value of its enumeration, is refused with a plain reason before
anything is queued, and nothing is cut or rounded to fit; so is a text that is no literal. A text of
the untyped grammar below that the declared type refuses (40000 for an INT, TRUE for an INT)
keeps that grammar, so that it is queued and refused at the boundary as before. At an address a
variable is bound to, the admitted value is written as the image holds that variable: its bytes, or
for a TIME bound in whole milliseconds those milliseconds. The parser is the shared one of every
runtime (specification 34), so a board takes a text exactly as trust-runtime does.
The untyped grammar. Where the destination's type is not known (an address no variable is bound
to, a target the installed program does not resolve, a runtime without its program at hand), after
trimming, TRUE and FALSE match ASCII-case-insensitively and produce Boolean values.
A base-ten signed integer accepted exactly by the i64 domain produces a
LINT; optional leading sign and leading zeroes are accepted, while an empty
string, separators, base prefixes, decimal points, exponent notation, quoted
text, and out-of-range integers reject as unsupported value. This admission
grammar does not bypass target-width or declared-type checks when the queued
mutation is applied at a scan boundary.
An admitted integer is an untyped literal (IEC 61131-3 Ed.3 §6.3.2, Table 5): a
literal without a type prefix takes its type from its destination, and a positive
integer may serve as a bit-string literal. When an io.write or io.force
mutation is applied, an integer destination (SINT to ULINT) takes the value
in its declared type if
the value lies in that type's range, and a bit-string destination (BYTE to
LWORD) takes a non-negative value that fits its width. Any other value or
destination type rejects that request alone: it reports a write_rejected
event, changes neither the destination nor the force set, and does not fault
the PLC (specification 34 §4.3.1). For example, io-write %MW0 41 stores
INT 41 into an INT binding, and io-write %MW0 40000 is rejected. Variable
mutations (set, var.force) apply the same rule to the declared type of their
target variable: set global:Counter 41 stores INT 41 into an INT global,
and a value the declared type cannot take rejects that request alone, as above.
An accepted io.write appends one one-shot request in arrival order. Multiple
writes to the same address remain distinct ordered requests. An accepted
io.force creates an active force; forcing the same exact address again
replaces its value in place without duplicating or reordering the force.
io.unforce removes only the exact matching force and is idempotent when no
such force exists.
Pending one-shot io.write requests, pending variable writes, pending debugger
expression writes (setExpression), active I/O forces and active variable forces
are each bounded at 1,024 entries per runtime (specification 34 §4.3.1). A request that would add an entry beyond its bound is
refused with the stable error request queue full and changes nothing.
Re-forcing an already forced target replaces its value in place and is never
refused by the bound.
The bounded eval operation trims expr, rejects an empty name, and performs
an exact snapshot lookup: global storage first, then retained storage. It is a
name read, not general expression execution, and it keeps its text expr
field; debug.evaluate and the other expression surfaces use the compiled form
of section 6.13.2. An absent snapshot returns
no snapshot available; an unknown name returns unknown identifier.
Successful values use the stable runtime debug representation.
Installed retained targets are declaration-qualified views of ordinary PLC
storage, not a second retain bank. The label for a retained global is its global
name; a retained program member uses @program/<instance>/<member>, matching
checkpoint labels. retain:<label> writes and forces resolve the installed
retained declaration against the current live program root at the cycle boundary
and use the common checked-write policy and cumulative cycle allowance. Missing,
non-retained, retired or type-incompatible destinations report write rejection
without creating storage or a PLC execution fault. Queue/force acceptance remains
distinct from application: existing syntactically valid request acknowledgements,
original target labels, deduplication and release behavior are unchanged. Retain
list presentation remains in declaration order. Isolated detached inspection
fixtures may still expose an explicit legacy retain bank; installed execution
never falls back to that bank. Exact eval name lookup is preserved; DAP retained
name matching remains ASCII case-insensitive through the same immutable index.
Shared checkpoint capture also rejects duplicate serialized labels with a retain
migration error before returning or saving a snapshot. A global name that equals
a generated @program/<root>/<member> key shall never silently replace either
retained value. This applies to the shared core on every platform; it does not
change explicit insert-or-replace operations on already collected snapshot records.
Shared snapshot application shall reject colliding destination labels under its
existing ASCII-insensitive name matching before mutating PLC values. Distinct
declared destinations must not silently replace each other in the restoration index.
Hosted installation rejects ambiguous retained labels, including a global label colliding with a synthetic program-member label under ASCII case folding. The failed candidate must not replace the installed controller or its published state.
Variable mutation targets use exact lowercase prefixes. set admits
global:<path> and retain:<name> only. Force and release additionally admit
instance:<id>:<name>, where id consists only of ASCII decimal digits in
the inclusive u32 range. A global: target is a variable path: a global or a
qualified variable by its name (Main.Speed), and also a member of an instance
at any depth, a field of a structure and an element of an array with constant
indices (Main.Pumps[2].Speed, GVL.Table[1, 3]), the names compared without
case, resolved through the installed program's tables at each boundary
(specification 44 §13.3). A constant, a member or an element of one, and an
instance, which holds no value of its own, are refused with a plain reason. Surrounding whitespace on the complete target is
not ignored because it changes the prefix; whitespace around the name after
the delimiter is trimmed. Every name must remain non-empty. Instance names
retain all text after the second delimiter, after surrounding whitespace is
removed.
An accepted set queues a one-shot variable write for the next scan. A later
pending write to the same exact target replaces the earlier value in place;
different targets retain arrival order. Force uses the same target identity
and scalar grammar. var.forced returns active variable forces in stable
insertion order with canonical targets global:<name>, retain:<name>, or
instance:<normalized-u32>:<name> and stable debugger-formatted values.
Re-forcing one target replaces its value without duplication. Release removes
only the exact target and is idempotent.
6.8.1a Offline communication authoring client¶
trust-runtime comm is a scriptable local authoring surface. Each accepted
subcommand delegates to exactly one matching offline communication operation
and emits that operation's JSON result:
schemaoptionally limits the schema request to the selected protocol;topologyreads the selected project and reports its offline fleet topology;applyrequires--paramsto decode as a JSON object, forwards the selected protocol and action, and includesinstance_idonly when one was supplied;testrequires--paramsto decode as a JSON object and runs thecomm.testrequest's code for the selected protocol against the selected project, printing that request's answer;discoverforwards the selected protocol, origin, passive flag, and only the optional scope fields that were supplied by the caller; each--ads-portgiven is forwarded, in the order given, asscope.ads_ports, which the request merges and sorts;browse-symbolsaccepts an optional JSON-object target, optional cached JSON snapshot, instance, and connection name, and selects project-aware browsing only when a project was supplied;opcua-trust listreports the selected client PKI directory and its trusted server certificates, whileopcua-trust clearremoves those trusted certificates and reports the number removed.
comm discover defaults to passive read-only discovery when --passive is
omitted and preserves omitted host, adapter, and other optional scope fields as
absent. comm browse-symbols defaults kind to symbols and preserves omitted
project, snapshot, and instance inputs as absent.
Malformed JSON, a non-object --params or target, an unreadable or malformed
snapshot file, and any delegated operation failure are command failures. They
must not be printed as successful communication results. These offline CLI
rules are truST host-product behavior outside IEC 61131-3 and are neither an
IEC decision nor an IEC deviation.
The default runtime template consumed by offline topology and apply operations must itself satisfy current runtime configuration validation. An unset optional mesh authentication token is omitted; the template must not serialize an empty secret value that the parser rejects even while mesh is disabled.
Communication schema, capabilities, and Test¶
Communication schema and capability responses use schema version 4. Protocol filtering returns only the requested protocol. Every schema entry has a stable protocol ID, category, apply mode, configuration owner, multi-instance capability, and typed field definitions. Secret fields are marked as secret metadata, have no secret default, and never serialize a credential value.
The schema registry is a closed, deterministic product contract. Its protocol
order is modbus_tcp, mqtt, ethercat, gpio, simulated, loopback,
opcua, opcua_client, openot, discovery, mesh, realtime_t0,
runtime_cloud, ads_server, and ads. Protocol IDs and action IDs are
unique. Filtering trims surrounding whitespace, folds ASCII case, and treats
hyphens as underscores; an unknown string selects no protocols, while a
non-string protocol filter is malformed and must not silently return the
unfiltered registry.
Each protocol has non-empty title, purpose, availability, primary category,
configuration home, apply mode, lifecycle effect, and at least one category
containing its primary category. The closed configuration homes are
io.toml, runtime.toml, opcua_client.toml, and ads.toml; every protocol
must route to the apply implementation that owns that home. The closed action
vocabulary is add, edit, upsert, remove, disable, discover,
browse_symbols, test, doctor, and route_script. Actions are unique per
protocol. supports_test is true exactly when test is advertised; a schema
must not expose one half of a usable Test control.
Field IDs are non-empty and unique within a protocol. Labels and help are
non-empty. The closed field-type vocabulary is string, secret, boolean,
number, enum, endpoint, path, json_array, and json_object, and a
default has the matching JSON shape. Required fields have a non-null default.
Only secret fields carry secret = true; their default is always null and
they are never required. Enum options are non-empty, unique strings containing
the default. Number validation uses a complete integer range with min <= max
and a default within that range. Endpoint validation is either host_port or
socket_addr. A visibility predicate references another field in the same
protocol and its comparison value is valid for that controlling field.
The schema also carries the words of the add picker and of the setup forms
(specification 25 §5.4 and §5.9), so that the tools show the words the catalog
gives instead of keeping a copy of them. These keys are an additive part of
version 4: a client ignores a key it does not know, and a runtime that
predates them omits them. picker_groups lists the picker's groups in their
canonical order; each group has a stable id, its label, whether it is
advanced, and the protocols it holds, in their order:
id |
label |
advanced |
protocols |
|---|---|---|---|
devices_io |
Devices and I/O |
false | modbus_tcp, ethercat, gpio, simulated, loopback |
read_tags |
Read tags from another PLC or server |
false | opcua_client, ads |
share_values |
Share truST values |
false | opcua, ads_server |
messages |
Send and receive messages |
false | mqtt |
advanced |
Advanced integrations |
true | mesh, openot, realtime_t0, runtime_cloud |
Every protocol of a group carries picker, its entry in the picker, with a
title, a badge, and a purpose. These are the picker's words; the
protocol's own title and purpose remain the words of its form.
| Protocol | title |
badge |
purpose |
|---|---|---|---|
modbus_tcp |
Modbus |
MB |
Connect PLCs, drives, meters, and remote I/O. |
ethercat |
EtherCAT |
EC |
Connect EtherCAT drives, terminals, or remote I/O. |
gpio |
GPIO |
IO |
Use Raspberry Pi or Linux controller pins. |
simulated |
Simulated I/O |
SIM |
Practice and test without hardware. |
loopback |
Loopback I/O |
LOOP |
Echo outputs back into inputs for quick checks. |
opcua_client |
OPC UA client |
UA IN |
Read selected tags from another OPC UA server. |
ads |
ADS client |
ADS IN |
Read tags from a TwinCAT or ADS PLC. |
opcua |
OPC UA server |
UA OUT |
Share truST values with SCADA, HMI, or historians. |
ads_server |
ADS server |
ADS OUT |
Share truST values with TwinCAT or ADS clients. |
mqtt |
MQTT broker |
MQTT |
Send and receive process values through MQTT topics. |
mesh |
Mesh / Zenoh |
MESH |
Configure a peer network; no live link is active until a runtime reports one. |
openot |
OpenOT |
OT |
Configure OpenOT evidence output; no evidence is published until a runtime reports one. |
realtime_t0 |
Realtime T0 |
RT |
Configure deterministic same-host exchange; no live exchange runs until a runtime reports one. |
runtime_cloud |
Runtime cloud |
CLOUD |
Configure federation policy; no live link is active until a runtime reports one. |
discovery belongs to no group and carries no picker: the runtime discovery
settings are edited from the runtime's Runtime discovery connection, not
added as a protocol card (specification 25 §5.9 and §5.13).
Every enum field carries option_labels, an object that gives each of its
options the label that a form shows. The options of on_error read
Stop with fault (fault), Warn and continue (warn), and Ignore
(ignore); scheduler reads FIFO (fifo), Round robin (rr), and
Default (other); source reads Heartbeat (heartbeat) and ST producer
(st-fb); fence_mode reads Fenced (fenced) and Unfenced (unfenced);
and profile reads Development (dev), Plant (plant), and WAN
(wan). Every other option is labelled with its value.
Every json_array field carries list, the words and the seed of its list
editor: the item_noun that names one item, the empty_text shown while the
list is empty, and the item_template that adding an item appends when the
list holds no item to copy. The nouns of connect, producer_instances,
wan_allow_write, link_transports, cpu_affinity, expose, modules,
mock_inputs, and selected_channels are peer address,
ST producer block, WAN write rule, link transport, CPU index,
global, module, mock frame, and channel. Any other noun is the field's
label in the singular, with its acronyms kept in capitals and its other words
in lower case, such as allowed client for Allowed clients and TLS ALPN
for TLS ALPN. The connections lists of opcua_client and ads read
No connections yet. Use Discover or Browse to add one instead of typing JSON.,
expose reads
No globals selected yet. Use Choose globals to pick project variables, or add a pattern manually.,
and every other list reads No <label> yet. Add an item below., with the
label's first letter in lower case unless its first word is an acronym. A list
whose items the catalog describes (below) starts a new item from that
description. Any other list's template is the first item of the field's
default, an empty string when that default is empty, and null for the
connections lists, whose items come only from Discover or Browse.
The items of four lists are described, so that adding an item gives one that
the runtime's configuration accepts once it is filled in. A list of numbers
carries item_type unsigned: each item is a whole number of 0 or more, and a
new item is an empty string, which is not a number yet. A list of objects
carries item_fields, the properties of an item in their order, each with its
id, label, type (string or enum), whether it is required, and the
options of an enum; none of them has a default, so a new item holds each
property as an empty string. item_one_of names the groups of properties of
which an item sets exactly one, each with its fields, of which a new item
holds only the first, and unless, the protocol's boolean field that, while
on, lets an item set none of them. The descriptions follow the runtime's
configuration parser:
realtime_t0cpu_affinity(runtime.realtime.cpu_affinity):item_typeunsigned; the parser reads a list of unsigned integers whose entries are unique.ads_serverclients([[runtime.ads_server.clients]]):ams_net_id(AMS Net ID, required),source_ip(Source IP), andsource_cidr(Source CIDR), with the groupsource_ip,source_cidrunlessallow_unpinned_clients; the parser requires one of the two, refuses both, and accepts neither only withallow_unpinned_clients = true.runtime_cloudwan_allow_write(runtime.cloud.wan.allow_write):action(Action) andtarget(Target), both required; the parser refuses an empty one.runtime_cloudlink_transports(runtime.cloud.links.transports):source(Source) andtarget(Target), both required, andtransport(Transport), a requiredenumofrealtime,zenoh,mesh,mqtt,modbus-tcp,opcua,discovery, andweb, the transports the parser accepts.
Schema defaults and apply validation are one contract, not separate sources of
truth. Every directly applicable default set is accepted by its owning
validator. Every advertised integer bound, enum option set, endpoint shape,
required string, array shape, and object shape is enforced by that same apply
route. ADS-client and OPC-UA-client connection arrays are intentionally empty
authoring seeds: while enabled is true, they are incomplete until the user
selects at least one connection and point/node, and the apply route must reject
activation without that selection.
Configured io.toml instances retain stable protocol-qualified IDs and appear
only under their matching protocol. Instance parameters are an observation
projection, not a credential retrieval surface: secret-valued keys such as
passwords, tokens, and private keys are omitted or replaced with a non-secret
presence marker before serialization. Schema filtering and configured-instance
projection must never cause a persisted credential to appear in a response,
diagnostic, or display name.
The version-4 capability registry contains exactly the 15 registered communication protocols. Build availability, platform availability, configuration state, and operational evidence are distinct. Every protocol shown by default on the current platform must have a built backend; an unknown platform marker fails closed instead of silently removing that requirement.
Two capability entries carry live health beside their version-4 shape, and no
other entry does. The discovery entry reports enabled, whether runtime
discovery is enabled, announcing, whether it announces itself, and
visible_runtimes, how many other runtimes it currently sees. The
mesh entry reports peers, one object for each configured peer in
configuration order, with peer, the configured target, connected, whether
the mesh's liveliness reports that peer now, and last_seen_ms, the
millisecond timestamp when the mesh last heard from it, null while it never
did. A runtime without mesh evidence reports every peer as not connected and
without a last-seen timestamp.
A communication Test request may succeed at the control-envelope level while
returning result.ok = false for a completed negative protocol test.
Unsupported Test operations return supported = false and must not appear as
usable controls.
comm.test requires a non-empty string protocol and an object-valued params;
an absent request or protocol fails at the control-envelope boundary, while a
present non-object params value returns a structured params field error
before protocol work rather than being reinterpreted as an empty target.
Protocol normalization follows the communication-schema rules. The supported set is exactly Modbus TCP,
MQTT, OPC UA client, simulated I/O, and loopback I/O. Simulated and loopback
return successful local_driver evidence without opening a socket. EtherCAT
and GPIO return a stable hardware-health explanation with supported = false.
Every other registered or unknown protocol returns the stable unsupported
result without network activity, evidence, or field errors.
Modbus and MQTT Test are bounded TCP-reachability probes, not protocol
handshakes. Their positive detail and evidence therefore claim only that the
resolved port accepted a TCP connection. Modbus accepts a host, IPv4 address,
or bracketed IPv6 address with an optional port and defaults an omitted port
to 502. MQTT target syntax and default ports are specified by
32-mqtt-io.md.
An explicit valid port is retained. Empty hosts, zero or invalid ports,
userinfo, paths, queries, fragments, unsupported schemes, and ambiguous
unbracketed IPv6 are field errors before DNS or connection activity. A
resolution failure is a field error; a resolved but refused or timed-out
connection is a completed negative Test with target, resolved address, and
effective timeout evidence.
The Test timeout defaults to 500 ms and is bounded to 1 through 5,000 ms. Zero and values above the maximum clamp to the nearest bound. Negative, fractional, string, object, array, and boolean timeout values are field errors; they must not silently select the default. The effective bounded timeout is reported in evidence. Address resolution and connection attempts share that single total budget and try resolved addresses in deterministic resolver order until one succeeds or the budget expires.
OPC UA client Test accepts endpoint_url or the host alias. A bare host
defaults to opc.tcp://<host>:4840; an explicit port or path is retained
under opc.tcp://, and an already canonical URL is byte-preserved after
trimming. Security policy and mode use the OPC UA configuration vocabulary.
Authentication is anonymous or the username, user_name, and user
aliases; username authentication requires non-empty trimmed username and
password values. The trust-server-certificate flag defaults false. A target
validation failure is field-specific and starts no handshake. A completed
handshake failure retains its stable OPC UA error classification and
credential-free endpoint/security evidence.
Any non-empty nested password, auth_token, token, secret, or
client_secret value requires the exact server-observed
trusted_same_host credential channel. Empty secret strings do not create a
credential. A blocked Test has no evidence or protocol error, never echoes
request parameters, and returns only a secret-presence-safe field error.
Communication capability health and fleet topology runtime, endpoint, and
link status are versioned product-projection vocabularies, not the
connector-report health vocabulary in
Connector Status. Capability health is one of
not_in_build, not_configured, simulate, runtime_unreachable,
connected, degraded, error, or configured_policy. Consumers must
interpret values within their response type and reject unknown values rather
than treating them as healthy.
The capability response order is ads, ads_server, opcua,
opcua_client, modbus_tcp, mqtt, openot, discovery, mesh,
realtime_t0, runtime_cloud, ethercat, gpio, simulated, and
loopback. Capability IDs, health values, platform values, and next-action
kinds are closed serialized vocabularies. Platform is unix for OpenOT and
EtherCAT, linux for GPIO and realtime T0, and absent for protocols without a
platform restriction.
Capability booleans and health are coherent. operational = true requires
built = true, configured = true, and health = connected; connected
likewise requires all three booleans true. not_configured requires
configured = false and operational = false. not_in_build requires
built = false and operational = false, while configured may remain true
because persisted configuration and build availability are independent.
configured_policy requires configured = true and operational = false.
Configured connectors with unavailable live evidence report degraded or
configured_policy, never connected.
An I/O capability matches its canonical driver name case-insensitively and
accepts the schema spelling alias with hyphens and underscores normalized.
When multiple live instances belong to one protocol, the projection is
conservative across the complete matching set: any faulted instance yields
error; otherwise any degraded instance yields degraded; only an all-healthy
non-empty set yields connected. Detail text must not hide a worse matching
instance merely because a healthier instance was registered first.
ADS client capability maps healthy to connected, faulted to error,
disabled to not_configured, and degraded, not_ready, or unknown to
degraded. OPC UA client capability is not_configured without configured
connections, degraded when configured status is unavailable,
configured_policy before a live read exists, error if any connection is
faulted, connected only when every connection is connected with zero
degraded points, and otherwise degraded. Build-disabled projections retain
the independent configured fact but are never operational.
The next-action projection is deterministic. Connected health uses none;
not-in-build uses get_build_with_feature; simulation uses
switch_to_online; runtime-unreachable uses open_runtime_pane;
not-configured and configured-policy use setup; and degraded or error uses
diagnose_ads for ADS and setup for other protocols. Action labels are
non-empty.
Read-only communication audit details are recursively credential-free.
Credential key matching is ASCII-case-insensitive and covers password,
auth_token, token, credential, credentials, secret, and
client_secret at every object depth, including objects inside arrays.
Non-secret protocol, target, scope, snapshot-metadata, and selection fields are
preserved. Audit sanitization never replaces a secret with its value, embeds it
in detail text, or relies on the caller placing credentials at a particular
depth.
Communication apply mutation and security¶
Server-observed transport identity determines whether a credential channel is trusted; a caller cannot self-assert trusted status. Secret-bearing input on an untrusted channel is rejected before mutation. Responses, diagnostics, and audit records may report secret-field presence but never secret values. Authorization and complete request validation precede mutation. Field errors are stable and cause no configuration write to be attempted.
Dry-run performs the same validation and lifecycle planning without writing or
deleting files and without returning a secret-bearing snippet. Add, upsert,
edit, remove, and disable preserve unrelated instances, project comments
outside rewritten driver/safe-state items, and safe-state values unless an
operation explicitly owns that data. Edit requires
one exact selected instance. Instance IDs are protocol-qualified; missing,
stale, cross-protocol, and wrong-slot IDs fail closed. Repeating an identical
edit is byte-identical. Disable retains the selected instance with
enabled = false so topology can show it as disabled.
A valid single-driver file may migrate to canonical multi-driver form when
another driver is added. Empty, comment-only, and safe-state-only files are
valid authoring inputs, but successful output must satisfy runtime activation
validation. Removing the final project driver preserves the project file,
project comments outside rewritten driver/safe-state items, and exact
safe-state values, writes an explicit driver = "none" with empty parameters,
and must not activate system-I/O fallback. A later Add replaces that sentinel
while preserving the retained project policy.
Runtime-backed protocols update runtime.toml and their owned sidecars as one
validated authoring operation. Audit records include operation, protocol,
result, dry-run state, and secret presence without secret values.
The runtime-file authoring boundary is project-confined and transactional.
Every configured sidecar path is trimmed, must be relative to the selected
project root, and must remain below that root after lexical normalization and
existing-parent canonicalization. Absolute paths, empty paths, .. escape,
and symlink escape are rejected before any directory or file is changed.
runtime.toml and every owned ADS or OPC UA sidecar are rendered completely
in memory, parsed by their production configuration parsers, and staged before
publication. A successful operation replaces the complete output set; a
failure staging, validating, creating, writing, syncing, renaming, or removing
any member preserves the complete pre-operation set. It must never publish a
new runtime.toml that points to an absent, stale, or partially written
sidecar.
Dry-run and Validate perform the same normalization, path-confinement,
rendering, and production-parser checks but create no directories, temporary
files, sidecars, or runtime file. Add, Edit, and Upsert are byte-idempotent for
identical normalized input. For protocols with an enabled field, Disable
retains the existing owned configuration and changes only that field; Runtime
Cloud, whose absent section denotes disabled policy, removes its cloud
section. Remove deletes the runtime section and its owned sidecar in the same
transaction; a missing sidecar is an idempotent success. Malformed or
unreadable existing input fails closed without replacing it. Unrelated runtime
sections, comments, and ADS-server clients not supplied by an update remain
intact. No failure response, audit entry, temporary-file name, or persisted
document includes a secret value.
A successful io.toml authoring reply for add, upsert, and edit reports
instance_id as the project-local identifier of the connection it wrote,
{protocol}:{index}, where protocol is the canonical protocol ID and index
is that driver's zero-based position in the written io.toml. Validate, a dry
run, and a refused request do not recompute the identifier and answer as before.
Offline communication operation and fleet-topology truth¶
Offline schema, apply, and topology require no live runtime and must not invent
live evidence. Offline responses remain schema version 4. ADS authoring writes
coordinated runtime.toml and ads.toml; OPC UA client authoring writes
coordinated runtime and sidecar state, retains disabled configuration on
disable, and removes the selected section and sidecar on remove. Configured
OpenOT appears as configured evidence without a live payload. Offline results
exclude passwords, tokens, source allowlists, and other secret-bearing fields.
Offline topology requires a readable valid runtime.toml. A missing optional
io.toml is an empty configured-driver set; a present malformed or unreadable
file is an error rather than an empty topology. An enabled ADS or OPC UA client
pointer requires its readable valid project-confined sidecar. Disabled pointers
do not read or require their sidecars. Relative sidecars resolve from the
selected project; absolute paths and normalized or symlink escapes reject
before a read.
The offline host and runtime are configuration evidence: source is config,
runtime mode is stopped, health is configured_policy, and host/runtime live
timestamps, runtime load, endpoint live payloads, and host temperature, uptime,
and load are absent. Configured service and enabled-driver endpoints remain in
configuration order with their offline detail and policy/disabled health.
Offline links are sorted and deduplicated by stable link ID. A parser or
sidecar failure reports the owned file and reason but never includes a secret
value or publishes a partial topology.
Fleet topology identity and roles come from the selected project and current
runtime evidence. Configuration determines runtime identity, endpoint roles,
counterparts, stable link IDs, and external-node relationships. Merely enabling
a service projects configured_policy, not a live connection. Live values and
connected status require bound runtime evidence; degraded and faulted evidence
retains its detail. Every link reports stable ID, source, destination, protocol,
role, direction, and evidence-derived status. Fleet output never serializes
MQTT, mesh, ADS, OPC UA, or other credentials.
Fleet topology schema version 4 contains hosts, links, shared, and
external arrays and omits an empty discovered array. IDs are deterministic
ASCII projection keys. The common sanitizer lowercases ASCII alphanumeric
characters, replaces each other character with a hyphen, trims boundary
hyphens, and returns unknown if no alphanumeric character remains. Local host
identity prefers the first non-empty normalized value from HOSTNAME,
COMPUTERNAME, and the operating-system hostname; normalization trims
whitespace and trailing DNS dots. A container identity is the first
reverse-scanned cgroup component containing at least 12 hexadecimal characters,
or the hexadecimal component of a docker-<id>.scope record, truncated to 12
characters. Short, non-hexadecimal, and empty components do not become
container IDs.
Configured host IP projection parses valid listen values as hosts, removes
unspecified wildcard addresses, sorts and deduplicates the result. When no
concrete host remains but a listen value was an unspecified wildcard address,
the projection reports the computer's real non-loopback interface addresses in
their place, sorted and deduplicated. It falls back to 127.0.0.1 only when
neither a concrete host nor an interface address remains. Bracketed IPv6 is
unbracketed in the host inventory. Whenever an IP address is combined with a
port for an endpoint, IPv6 is bracketed so the result is an unambiguous socket
authority; IPv4 and hostnames use host:port.
Discovery timestamps floor nanoseconds to milliseconds. An entry is stale only
when its age is strictly greater than 120,000 ms; future timestamps remain
fresh by saturating age at zero. The newest observed timestamp is the maximum
over the complete entry set and is absent for an empty set. An entry is self
when either its runtime name or discovery ID equals the current runtime ID and
is excluded from discovered-host and discovered-node projections.
Discovery-derived protocol order is discovery, then web, mesh, and
control when those advertised surfaces exist. Discovery IDs use the
sanitized runtime name for runtime nodes, the host-group when supplied for
host nodes, and otherwise the discovery ID. Shared host identity requires a
matching non-empty host group on a self entry; name similarity or address
overlap alone does not claim same-host placement.
Configured I/O endpoints retain configuration order and use the configuration
index in their stable endpoint ID. The first protocol instance omits an index
suffix; later instances include their zero-based configuration index. Driver
protocol normalization trims and folds ASCII case, maps hyphens to
underscores, and canonicalizes both modbus-tcp and modbus_tcp as
modbus_tcp. Roles are client for Modbus TCP and MQTT, master for
EtherCAT, and owned_driver otherwise. Display names use the trimmed address
or broker when present; otherwise the first configured instance uses the
protocol display name and later instances append their one-based ordinal.
An enabled configured I/O endpoint binds to the corresponding enabled runtime
health row while disabled configured rows consume no live-health ordinal.
Healthy, degraded, and faulted driver evidence map to connected, degraded,
and error with the underlying detail retained. Missing health is
configured_policy; a disabled endpoint is disabled, has no live sample,
and does not advertise Test. Test support is true exactly for an enabled
canonical Modbus TCP or MQTT endpoint, regardless of accepted hyphen,
underscore, or ASCII-case spelling. Health-only endpoints use the same
canonical protocol, role, health, ID, and Test rules.
I/O snapshot live evidence exists only when both a snapshot and a nonzero
observation timestamp exist. It reports exact input, output, and memory counts
and at most eight deterministic samples in input-then-output-then-memory order.
Each sample retains direction, optional name, canonical IEC address, and either
the debug-form scalar value, an error object, or unresolved. Bit, byte, word,
double-word, long-word, fixed-byte, and wildcard addresses preserve area and
size without inventing a bit coordinate for non-bit values.
Fleet endpoint parameters recursively preserve the TOML JSON shape while
redacting secret or access-control values at any depth, including tables inside
arrays. Credential matching is ASCII-case-insensitive and covers password,
auth_token, token, credential, credentials, secret,
client_secret, and private_key. ADS source pins and allowlists
(source_ip, source_cidr, allowed_clients, and clients) are likewise
redacted rather than exposed as editable topology data. A redacted value is the
literal non-secret marker <redacted>; no sibling value or container shape may
reveal the original credential.
ADS and OPC UA client endpoint projections retain the configured connection denominator and order. ADS parameters expose route name, target identity, host, AMS port, whether a local identity is set, transport class, automatic route policy, and point mapping, but never a route credential. Symbol points and index-group points remain distinct. OPC UA parameters expose endpoint, security policy and mode, authentication class, a Boolean username-presence fact, trust policy, timing, and point mapping, but never username credentials or a password. Read, write, and read-write access serialize exactly and the writable flag follows access rather than live status.
Live connection evidence matches ADS configuration by exact connection name
or target AMS Net ID plus AMS port, and OPC UA configuration by exact
connection name or endpoint URL. Unmatched live rows cannot upgrade a
configured connection. Exact name identity takes precedence over a fallback
target or endpoint match so one stale row cannot cross-bind two configured
connections. A missing report or a report with no matching row is
configured_policy; a matching fault dominates as error; otherwise any
non-connected state or degraded point yields degraded; only a non-empty set
where every configured connection has a matching connected row with zero
degraded points is connected. A partial match is therefore degraded rather
than connected or configured-only.
ADS live state labels come from the ADS diagnostic contract. OPC UA state
labels are disabled, configured_policy, connecting, connected,
reconnecting, stale, and error. Per-connection topology health maps
connected with zero degraded points to connected, faulted to error,
connected with degraded points and all transitional live states to
degraded, and disabled/configured to configured_policy.
Live summary counts, connection arrays, and freshness timestamps are computed
only over the configured connections and their matched status rows. Extra or
stale status rows from an earlier configuration do not affect connected,
total, or last_seen_ms. Each configured connection remains present even
when unmatched, with null live fields. Freshness is the maximum matched
per-connection or per-point observation; when a report exists but supplies no
timestamp, the projection may use the projection time but must not claim an
unmatched row's timestamp.
Fleet link IDs are
link:<sanitized-protocol>:<sanitized-role>:<sanitized-from>:<sanitized-to>.
The link retains its unsanitized source, destination, protocol, role,
direction, detail, same-host fact, status, and security fact separately.
Configured links remain present while disabled or while live evidence is
missing; their status is respectively disabled or configured_policy.
Healthy, degraded, and faulted driver evidence maps to the same link status as
its endpoint.
Configured Modbus targets project an outbound client link to an external device
identified by the trimmed address. Configured EtherCAT adapters project an
outbound master link to an external fieldbus segment identified by the trimmed
adapter. Empty or non-string targets do not create external nodes or links.
Modbus link security follows its Boolean TLS configuration; EtherCAT does not
claim transport security. MQTT brokers project a publish-subscribe link to one
shared broker node per exact trimmed broker address, sorted deterministically,
with the runtime listed once in used_by; broker security follows its Boolean
TLS configuration.
ADS and OPC UA client links retain configured connection order and remain
configured_policy without a matching status row. ADS security follows the
route transport, and OPC UA security is false only for policy none. ADS
external identity uses the target AMS Net ID; OPC UA external identity uses the
sanitized endpoint URL. Their link status follows the matching per-connection
health contract above and never derives from an unrelated status row.
Configured mesh targets are configured_policy without mesh evidence,
connected only when the exact target or its embedded peer identity is in the
live peer registry, degraded when the mesh session is ready but that peer is
absent, and otherwise configured_policy. Merely having a configured target
does not prove a live mesh link.
Shared MQTT nodes are grouped by exact trimmed broker address in lexical order. Duplicate configured drivers contribute the local runtime ID once. Drivers without a non-empty broker create neither a shared node nor a shared link.
External-node projection order is configured mesh targets, unresolved runtime cloud targets, observed discovery peers, configured driver targets, ADS targets, and OPC UA targets. IDs are unique in the result and the first authoritative source for an ID wins. A runtime-cloud link with an explicit managed target does not create a fabricated external placeholder; an empty target creates the stable indexed policy placeholder. Self-discovery entries are excluded from external, discovered, host, runtime, endpoint, and link populations.
Each non-self discovery peer appears once in the discovered inventory with
its exact addresses, advertised optional control/web/mesh fields, stable
protocol order, observed direction, adopted = false, discovery source, and
floored last-seen timestamp. Duplicate observations with the same discovery
identity collapse without reordering the first observation.
Discovered entries sharing a non-empty host group coalesce into one host node.
That host retains first-observation order, a sorted deduplicated IP union, the
newest host timestamp, and one runtime child per unique runtime identity in
observation order. Entries without a host group use independent
discovery-identity hosts. A discovered runtime is connected while fresh and
runtime_unreachable while stale. Advertised web and mesh endpoints are
observations, not listener probes; web follows runtime freshness, while mesh is
connected only with ready matching mesh evidence and otherwise
configured_policy. Discovered endpoints are unowned and never advertise
Test.
6.8.1b ADS CLI authoring and server-control contract¶
trust-runtime ads is the scriptable ADS authoring, validation, route, and
server-control surface. Offline import canonicalizes cached symbol snapshots,
generates deterministic ST, creates missing parent directories, reports an
unchanged byte-identical output without rewriting it, and refuses to replace
different generated content unless --force is explicit. Offline validation
requires a snapshot and fails visibly for missing symbols, type mismatches, or
generated-source drift. Live validation rejects cached snapshots and obtains
fresh symbols from every configured connection before applying the same
compatibility check.
ads remove-symbols removes selected imported tag points from a project's
ads.toml and regenerates the generated ST from the rewritten configuration and
every cached snapshot, running the same function as the ads.import_symbols.remove
request. It writes nothing when no point matched, and its answer reports the number
of points removed, the files written, and whether anything matched.
The ADS parser preserves every selected live-client action and its explicit
target, target Net ID, connection, configuration, guarded-write, and JSON
options. When --ams-port is omitted, client discovery and Doctor select AMS
port 851. ADS server status and symbols preserve their endpoint,
authentication, and JSON choices without selecting another server action.
Discovery, browse, Doctor, guarded route addition, live symbol import, and live
validation require the ads-wire feature. A build without that feature fails
before synthesizing hardware results or writing generated files. Doctor write
probe options are all-or-none, accept only the documented scalar types, and do
not weaken the guarded write/read-back/restore policy. Route-add credentials
are accepted only through non-empty standard input and are never echoed in
reports or failures. Route-script and route-remove remain credential-free,
deterministic artifact generation.
ADS server status, symbols, and Doctor commands use the same versioned control
response rule as trust-runtime ctl: the response must contain Boolean
ok = true before either human or --json output is treated as success.
ok = false and a missing Boolean ok return non-zero and preserve the server
error text; choosing JSON output must never turn a rejected control request
into success. External Doctor proof metadata is accepted only when kind and
name are supplied together. Human and JSON modes render the same underlying
successful report or artifact.
ADS browser-to-control boundary¶
Every ADS HTTP route delegates to its exact registered control operation and returns that operation's response; web routing alone does not manufacture ADS success. Local web authentication may supply the configured internal control token only to the in-process control dispatch that serves the authenticated request. The generic control proxy must not forward that token in headers, payload, logs, or error text to another target.
Credential-channel classification is derived from the observed request.
Loopback is trusted_same_host; a non-loopback request is
trusted_https_admin only when TLS is active and the authenticated role is
Admin; every other network request is untrusted_plain_http_network. The
server overwrites any caller-supplied channel before ads.route_add policy is
evaluated. Route-add responses and serialized failures never echo the supplied
username, password, or internal control token.
The runtime-host ADS setup page ships the Beckhoff ADS Setup,
This runtime host, Open IDE Deploy, ADS Server, and
Expected AMS Net ID onboarding labels, links to /ide, and exposes the
/api/ads/status and /api/ads/server/status routes from its local script.
It does not expose a runtime selector, a runtime target field, or a sample
MAIN.Temperature binding because the page configures the runtime host that
serves it.
Static setup-page asset checks establish only that this reviewed onboarding surface and its route names are shipped. They do not prove rendered interaction, authorization, control dispatch, ADS transport, or production readiness.
ADS AMS/TCP frame codec¶
The ADS server's stream boundary uses the six-byte AMS/TCP prefix followed by
the AMS frame. The prefix contains two zero reserved bytes and a little-endian
u32 length. That length and the configured max_frame_bytes limit cover the
32-byte AMS header plus payload and exclude the six-byte prefix.
A valid parsed frame preserves target and source AMS Net IDs and ports, command ID, state flags, header data length, AMS error code, invoke ID, and payload. Parsing rejects a prefix shorter than six bytes, nonzero reserved bytes, a declared AMS length below 32, a declared length above the configured cap, a frame shorter than its declared length, an unsupported command ID, request state flags without the ADS command bit, or a header data length that does not equal the payload length. The codec must reject those boundaries before inventing missing bytes or accepting an inconsistent payload.
Serialization requires the header data length to equal the payload length and emits the canonical prefix, header, and payload bytes. A response header swaps the request source and target endpoints, preserves command and invoke correlation, sets the response state, and records the supplied payload length. This codec contract does not by itself prove socket lifecycle, authorization, command dispatch, or external TwinCAT interoperability.
ADS server command dispatch and resource safety¶
The ADS server evaluates the client allowlist before decoding or executing a
command. A rejected client receives AccessDenied, emits one policy-rejection
audit event, and cannot allocate handles, register notifications, read runtime
values, or enqueue writes. Accepted commands require their exact ADS request
shape; truncated, extended, or internally inconsistent payloads return the
corresponding size/data error without guessing missing fields.
Direct, by-name, by-handle, system-symbol, upload, and sum-up reads preserve
the ADS group/offset distinction and the requested wire length. Symbol handles
and notification handles are non-zero and unique for the connection. Resource
limits fail with NoMemory or InvalidSize before inserting state; numeric
handle wrap searches for an unused value and must never replace a live handle.
Release or delete succeeds only for a live handle.
A direct or sum-up write reaches the runtime write port only for a resolved symbol with Write capability and emits an accepted or rejected write audit. Before a sum-up write or sum-up read/write performs its first item, the server validates the complete aggregate header, every declared write-data range, the exact aggregate length, the item limit, and the total write-byte limit. A malformed later item therefore cannot leave an earlier runtime write queued. After framing is valid, item-level address/access failures remain independent and are returned in their documented per-item result slots.
Device notifications require a receiver endpoint, a supported cycle or
on-change mode, a resolvable symbol/system target, and a non-zero watch length
no larger than that target. Notification time values that are exact ADS
100-nanosecond multiples of one millisecond are normalized to milliseconds.
Deletion invalidates only the named live registration. Unsupported
WriteControl and client-sent DeviceNotification commands remain
side-effect-free and return ServiceNotSupported.
Notification sampling clamps the requested cycle to the configured minimum.
SERVERCYCLE emits every due sample even when its bytes are unchanged.
SERVERONCHA emits the first due sample, suppresses later equal bytes, and
emits again after a byte change. Symbol values, little-endian symbol-source
versions, and static system bytes are truncated to the registered watch
length. Static system-byte targets do not read runtime value storage.
A failed or undersized source read emits an invalidated sample that preserves
the notification handle and contains zero value bytes; it does not fabricate a
value. For representable inputs, sample timestamps use Windows FILETIME ticks:
(unix_ms + 11_644_473_600_000) * 10_000. Recovery after an invalidated
on-change sample, timestamp overflow, clock rollback, sampling at Unix time
zero, and notification-handle reuse remain explicit behavior gaps rather than
being inferred from the current focused tests.
Service-port routing is evaluated relative to the configured runtime, system,
router, and TCOM ports. An unknown target port returns AMS AccessDenied with
no payload. System service port 10000 accepts ReadState, responds from port
10000 with ADS state 5 and device state 0, and rejects symbol Read with
ServiceNotSupported. Router port 1 reports device-info version 3.1 named
TCROUTER; an unknown router metadata group/offset returns AMS transport
success carrying ADS InvalidOffset and zero data length.
A notification registration binds its receiver AMS Net ID and port when the registration is accepted. Later requests multiplexed on the same TCP connection cannot retarget that registration. The router TCP/IP metadata entry-length rule remains unresolved: current behavior may return one complete 48-byte entry for a 40-byte request, while the general read contract preserves requested wire length. No catalog claim is made for that conflict.
ADS server runtime publication and lifecycle¶
ADS server publication is a truST runtime product contract outside IEC 61131-3. A default server configuration is disabled and carries the public, bounded defaults for ADS port, symbol/client/subscription/frame/sum-up/write limits, string-byte limit, read/idle timeouts, and minimum notification cycle. Those defaults are respectively ADS port 851, 256 symbols, 8 clients, 64 subscriptions per client, 256 total subscriptions, 65,536 frame bytes, 512 sum-up items, 8,192 write bytes, 4,096 string bytes, 5,000 ms read timeout, 60,000 ms idle timeout, and 50 ms minimum notification cycle. The default does not acknowledge insecure transport, enable writes, permit unpinned clients or public binds, or expose, write, or allow any configured entry.
Starting the enabled TCP service on an unavailable bind address returns the server bind-error class and no usable server handle.
An empty runtime.ads_server.expose publishes no symbols. Otherwise exposure
and writable globs match either the canonical ADS name or the bare runtime
global name, and an invalid glob fails publication rather than broadening it.
Publication considers the complete eligible runtime-global set, orders it by
canonical ADS name, and only then applies max_symbols; storage insertion
order must not choose the bounded subset. The v1 supported scalar descriptors
are BOOL, signed and unsigned 8/16/32/64-bit integers, REAL, LREAL, and
BYTE/WORD/DWORD/LWORD. A symbol is always readable and becomes writable
only when both the global write gate and a writable glob authorize it.
Runtime values for which descriptor conversion returns no supported descriptor
are omitted without suppressing supported sibling symbols; this omission rule
does not select the unresolved STRING or array descriptor policy.
Namespace mapping is exact and reversible between <namespace>.global.<name>
(or global.<name> without a namespace) and its runtime global; a foreign
namespace is not stripped.
The server-owned symbol-table builder sorts accepted names, rejects duplicate names, preserves declared capability flags, and assigns contiguous offsets from the configured index group using each descriptor's byte size. The same accepted symbol set therefore produces the same snapshot regardless of input order. Deterministic snapshot serialization is stable for an unchanged snapshot. The local symbol-version counter establishes version 1 on first observation, retains it while the complete serialized snapshot is equal, and advances when that serialized snapshot changes. This counter contract does not prove atomic live publication.
Each value read obtains a runtime snapshot from the configured provider. No
snapshot returns ADS NotReady; a foreign ADS name or absent runtime global
returns NotFound; an unencodable value returns InvalidData; and a byte-size
mismatch returns InvalidSize. Those failures return no fabricated value
bytes. The clock authority and threshold for classifying an available snapshot
as Good or Stale remain unresolved under
SPEC_GAP_P18_ADS_SERVER_SNAPSHOT_FRESHNESS_CLOCK_001; existing freshness
observations are not specification authority.
A disabled lifecycle returns no server without consulting the snapshot
provider or binding sockets. An enabled lifecycle requires a concrete bind IP
and AMS identity, initializes its symbol source from the available runtime
snapshot or an empty not-ready snapshot, and owns both the ADS TCP listener and
UDP identify responder. Runtime status accessors report the live addresses,
policy, client count, and served symbol table. Refresh retains the bound socket
ownership, while explicit shutdown and drop stop both listeners. The atomic
publication and version transition for a refreshed symbol table remains
unresolved under SPEC_GAP_P18_ADS_SERVER_SYMBOL_VERSION_ATOMICITY_001 and is
not test authority in this contract.
The capacity and descriptor policy for runtime STRING values remains
unresolved under SPEC_GAP_P18_ADS_SERVER_STRING_DESCRIPTOR_POLICY_001; the
public string-byte configuration bound alone does not select that behavior.
ADS server client policy, write-back, and audit¶
An ADS server client is authorized only when its AMS Net ID matches an
allowlist entry and its observed source matches that entry's parsed exact IP or
valid IPv4/IPv6 CIDR. Equivalent textual spellings of the same IP address are
equal; invalid IP or CIDR text and address-family mismatch fail closed. An
Unpinned entry remains AMS-Net-ID-only and is effective only while
allow_unpinned_clients is explicitly true, including when configuration is
constructed programmatically; when opted in, it does not require an observed
source. Missing source identity denies pinned Ip and Cidr entries, and
poisoned policy state denies all entries. A temporary permit authorizes only
its exact AMS identity and parsed source IP and is removed when its guard
drops.
Denied attempts retain bounded operator evidence. Repeated attempts with the same AMS identity, source, and reason coalesce by increasing the count and last seen time; distinct attempts retain at most 32 records and evict the oldest. Reasons distinguish an unknown AMS identity, a missing source, and a source outside the configured pin, and the suggested entry preserves the observed identity without authorizing it.
Runtime write-back checks, in order, the live client policy, the global write
gate, combined exact-namespace and writable-glob authorization, runtime
readiness, and ADS value decoding before it queues a global write. A foreign
namespace or a symbol outside the writable allowlist returns AccessDenied
before runtime readiness is inspected; a stopped runtime cannot change that
security-first result to NotReady. Ready, Running, and Paused resources
may accept an otherwise valid write. Boot, Faulted, and Stopped
resources, or any resource carrying a last error, return NotReady only after
the earlier authorization checks pass. Malformed ADS value bytes return
InvalidData. A malformed writable glob rejects write-port construction with
InvalidParameter. Every rejection is free of a queued PLC mutation. Accepted
writes use the runtime debug queue's normal same-target coalescing behavior.
The audit adapter emits one runtime control-audit record per accepted write,
rejected write, or policy rejection when a sink is configured. It preserves
the event timestamp, client AMS identity and observed source, symbol and value
type when present, stable ads.server.write or ads.server.policy request
type, stable kind, success state, and ADS error code/message. Audit identifiers
are process-local unique records; an absent or disconnected audit receiver must
not change ADS command behavior.
ADS diagnostic report and production-readiness truth¶
ADS Doctor reports use diagnostics schema version 2 and retain the documented snake-case or kebab-case wire names for roles, vantages, transports, steps, statuses, skip reasons, actions, connection states, and readiness reasons. Evidence and action maps serialize in deterministic key order. The report and route-plan schemas contain no credential, username, password, token, or other secret-bearing field; remediation may identify a credential channel but must not echo its contents.
A Doctor report is fail when it has no steps or any production-blocking step
is not pass. Otherwise any warning, skip, or explicitly non-blocking failed
step makes the report partial; only a non-empty set of passing steps makes it
pass. A report is never production-ready without both an overall pass and
role-appropriate production evidence. Client evidence binds the runtime and
target identities. Server evidence binds the runtime identity and allowed
clients, and additionally requires independent external-client proof marked as
verified with kind twincat (ASCII-case-insensitive), a non-empty client name,
and a client timestamp. Loopback self-test or pyads evidence remains useful
diagnostic proof but cannot establish TwinCAT production readiness.
Production evidence hashes the canonical runtime identity, target identity or
allowed-client set, ADS configuration, symbol snapshot set, optional generated
ST, deployed ADS configuration, and the live ADS status used by the Doctor.
Symbol-snapshot hashing is independent of connection ordering. A later
readiness evaluation is not_ready when Doctor evidence is absent. With
evidence present it is needs_recheck when live runtime status is absent, the
Doctor did not bind a live-status hash, deployed configuration evidence is
missing or mismatched, the current status hash changed, runtime ADS is not
healthy, the runtime clock was reported unreliable, or the evidence is older
than its declared freshness bound. The effective expiry is the earlier of an
explicit expiry and doctor_timestamp_ms + stale_after_ms; arithmetic overflow
must not extend evidence lifetime. Only evidence with none of those conditions
is ready.
ADS status summary¶
ads.status answers the runtime's ADS status report (its role, overall state,
summary and connections) with view, the summary that the tools show of it:
text is ADS: 1 device · <m> degraded for a report with one connection and
ADS: <n> devices · <m> degraded for more, where a connection is degraded
when it has a degraded point or is neither connected nor disabled; for a
report without connections it is the report's own summary, else
ADS is not configured.. overall is the report's overall
state word, device_count counts the connections and degraded_count the
degraded ones (0 without connections). The extension computes no summary of
its own and reads this one (26 October 2026, when the summary moved out of the
extension).
ADS onboarding identity authority¶
ADS onboarding identity is a truST product contract outside IEC 61131-3. The
selected runtime-host source address and the selected target address must each
be a syntactically valid IP address before they can become route, Doctor, or
symbol-import authority. An omitted local AMS Net ID is derived only from a
canonical IPv4 source as <ipv4>.1.1; IPv6 requires an explicit override. An
explicit override, including an explicitly blank value, must contain exactly
six decimal octets in the range 0 through 255 and is stored in canonical
decimal form. A malformed source address, target address, or override is
rejected before an onboarding identity is returned and before any ADS wire or
route side effect. Public and NAT-suspect identities remain ineligible for
automatic route creation.
ADS onboarding discovery endpoint identity¶
ADS onboarding discovery accepts a manual target, directed UDP identify, and optional directed-broadcast results. Manual identity does not depend on UDP when an AMS Net ID is supplied; without one, directed identify supplies the target identity. A failed broadcast candidate does not erase successful manual or other broadcast results.
Discovery uniqueness is the ADS endpoint identity (AMS Net ID, AMS port),
not the host IP address alone. Multiple PLC runtimes may share one host IP and
must remain separate results when their AMS Net ID or AMS port differs.
Repeated observations of the same ADS endpoint are reported once even if its
IP or discovery provenance differs. Directed-broadcast targets are derived
only from usable non-loopback, non-link-local IPv4 interface candidates and
are themselves deduplicated.
ADS onboarding live-wire and guarded-write recovery¶
The ads-wire onboarding boundary performs real UDP identify, TCP 48898,
route-back, AMS target, state, symbol, handle, sum-up, notification, guarded
write, and route-add operations without exposing raw ADS types in the public
Doctor schema. UDP identify collection ignores malformed replies but retains
every distinct (AMS Net ID, AMS port) endpoint even when multiple runtimes
share one host IP. Repeated observations of that same endpoint are emitted
once, and a receive window with no valid reply fails explicitly.
The live ADS transport cache is scoped to target IP, target AMS Net ID, target AMS port, and selected local AMS Net ID. Changing any component discards the transport plus cached symbols and handles before reconnecting. Symbol upload replaces the name cache; handle resolution may refresh that cache but must fail for an absent symbol. Sum-up reads reject unknown local handles, non-good quality, missing values, unknown returned point names, and value/type encoding failure instead of manufacturing bytes. Notification setup resolves the named symbol and subscribes in on-change mode.
A guarded write probe first requires a writable remote symbol whose complete type descriptor matches the requested descriptor, resolves its handle, and reads the original value. Once the probe write has been attempted, every write error, read-back error, or read-back mismatch must attempt to write and verify the original value before returning failure. A restore failure is retained in the returned diagnostic alongside the initiating failure. A successful probe is complete only after both probe read-back and restored-value read-back match. Automatic route addition continues to use the already validated runtime-host AMS identity and one-shot credentials; the transport never enables implicit route creation.
ADS client configuration and activation¶
ADS client configuration and activation is a truST product contract outside IEC 61131-3. Configuration parsing must fail closed before worker creation or any ADS wire activity unless all connection names are unique; every connection name and host is nonblank; every target AMS Net ID and explicit local AMS Net ID contains exactly six canonical decimal octets; every target AMS port is non-zero; and every connection declares at least one point. A configuration with no connections is valid and starts with no ADS bindings: removing a project's last imported tag leaves a rewritten file the runtime still starts, not an empty connection entry that fails closed. Plain transport requires explicit insecure-transport acknowledgement, and that acknowledgement is invalid for any non-plain transport.
Across the complete configuration, every declared global variable binding is unique. Each point supplies exactly one complete address form: either one nonblank symbol or the complete index-group, index-offset, and non-zero size tuple. Notification options are valid only for notify-mode points, and every point's IEC type, STRING capacity, array dimensions, address size, access, and update-mode metadata must be internally valid. Production-readiness activation additionally requires every configured local AMS Net ID to equal the selected runtime-host identity; a missing or mismatched local identity is not production-ready.
Secure transport remains an unresolved product decision: the configuration
schema currently recognizes a reserved secure value while the implemented
ADS transport rejects secure activation. This specification does not select a
new parser or transport behavior for that contradiction, and the contradiction
is not test authority until a separate product decision resolves it.
ADS core value codec and descriptor size¶
The shared ADS value codec is a truST product contract outside IEC 61131-3.
For the reviewed fixed-width scalar matrix, BOOL uses one byte with reviewed
true encoded as 1; signed and unsigned integers and bit strings use their
declared one-, two-, four-, or eight-byte width; and REAL and LREAL use
four and eight bytes respectively. Multi-byte values use canonical
little-endian byte order. The reviewed matrix proves exact encode/decode
round trips for its named representative values; it does not select behavior
for noncanonical BOOL bytes, every elementary value, or non-finite egress.
Non-finite ingress remains governed by the ADS client configuration and
activation contract.
For the reviewed STRING(8) partition, the declared capacity is eight payload
bytes and the fixed layout is nine bytes including the terminator. Decoding the
reviewed zero-padded buffer returns bytes through the first NUL, and encoding
Pump produces the exact trailing-zero-filled nine-byte buffer. This partition
does not select Unicode capacity, invalid UTF-8, an absent terminator,
over-capacity values, or a missing descriptor length.
For the reviewed one-dimensional INT[1..3] partition, decoding and encoding
preserve the inclusive bounds, element order, scalar representation, and exact
total byte extent. Multidimensional order, invalid or overflowing bounds,
element-count overflow, and array element-type failures remain outside this
contract.
The reviewed mismatch boundary rejects an input byte slice whose length
differs from the descriptor as ByteLengthMismatch, a scalar value supplied
for an array descriptor as ValueTypeMismatch, and an array whose bounds
differ from the descriptor as ArrayShapeMismatch. These observations do not
establish a complete precedence order among all possible mapping failures.
ADS symbol metadata is admissible at the reviewed descriptor-size boundary
only when the endpoint byte_size equals the descriptor-computed extent. The
reviewed WORD[1..4] extent is eight bytes and the reviewed scalar REAL
extent is four bytes. A REAL endpoint size of eight rejects as
ByteSizeMismatch with expected 4 and actual 8. Descriptor-size overflow
and every nested descriptor failure remain explicit gaps.
This shared codec authority does not select the unresolved runtime ADS server
publication policy for STRING or arrays. It also makes no wire,
external-endpoint, or TwinCAT interoperability claim.
ADS core point-quality lifecycle¶
The shared ADS point-quality record is a truST product contract outside IEC
61131-3. A reviewed cold-start status preserves its supplied point name and
starts in Stale; the cold-start partition does not establish the detail text
or a timestamp. An explicitly constructed reviewed stale record may preserve
the supplied last-good timestamp and detail.
For the reviewed in-memory transition sequence, an initial stale record has no
last-update timestamp. Marking it good sets Good, records the supplied update
timestamp, and clears stale detail. Marking that record failed sets Error,
replaces the timestamp with the failure timestamp, and records the failure
detail. Marking that error record stale sets Stale, preserves the last known
timestamp, and replaces the detail with the current stale reason.
These constructor and mutation rules do not select the clock source, timestamp units beyond the record's declared millisecond field, monotonicity, concurrent mutation, serde compatibility, worker publication timing, transport behavior, or external endpoint truth. The runtime ADS worker remains responsible for deciding when a transport or lifecycle event invokes these transitions.
ADS client transport boundary¶
The ADS client transport is a truST product contract outside IEC 61131-3. It must validate the required target AMS Net ID and an optional local AMS Net ID as exactly six decimal octets in canonical form, and validate the non-zero target AMS port, before opening TCP 48898 or performing any other wire operation. The runtime transport supports plain ADS only and must reject reserved secure transport or implicit route creation before the wire; route authoring remains an explicit onboarding operation outside this transport.
After connection authority validates and before replacing a client, the
transport attempts best-effort remote release of active notifications and
symbol handles. Remote release failure does not make remote cleanup
deterministic; the deterministic guarantee is that replacement, disconnect,
and drop tear down the transport's local handle maps, notification receiver,
and client ownership. ADS device state maps Run to runtime run, Stop,
Idle, and Config to runtime stop, Error and Exception to runtime fault,
and every other state to runtime unknown.
Symbol upload projects only supported scalar, array, and STRING metadata into the runtime descriptor model. Every projected symbol is readable; the ADS read-only flag withholds write access, and the persistent flag also establishes the runtime retain guardrail. Array dimensions, STRING capacity, and byte size must agree with the projected type. Unsupported metadata may be omitted, but inconsistent, overflowing, or otherwise unrepresentable metadata fails closed instead of producing a bindable descriptor.
A symbol-upload error establishes a missing return route when its typed kind is
RouteMissing, or when a NoSymbols failure explicitly describes a timeout,
no reply/response, route setup, or failure while receiving the reply. An actual
empty symbol-table response and a refused TCP connection are not reclassified
as route missing. This diagnostic classifier selects recovery guidance only; it
does not claim that adding a route will make the subsequent upload succeed.
By-name handle resolution first verifies that the remote symbol byte size exactly equals the requested type size and then acquires a remote symbol handle. Index-group/index-offset resolution verifies that its declared byte size exactly equals the requested type size and does not manufacture a remote handle. Replacing or removing a cached by-name handle releases the old remote handle.
Sum-up reads and writes preserve input order and cardinality. Each item retains its own conversion or ADS error as non-good quality; failed reads have no value, and failed writes are never reported as successful. The transport must not shift results between points, discard failed positions, or manufacture values to fill missing or malformed results. Subscriptions accept notify-mode points only, preserve the requested on-change or cyclic notification mode, and reject poll-mode points before subscription. Unknown notification handles and samples whose bytes do not decode as the resolved type fail closed with no manufactured value. The ADS symbol-version byte is an opaque equality token: a changed token invalidates symbol-derived state, but ordering, monotonicity, and wraparound must never be inferred from its numeric representation.
The connection worker constructs handle and subscription correlation as an unpublished candidate. Handle resolution must return exactly one response for every requested point, and every response must preserve that point's configured address and complete type descriptor. Response order is not authority, and the numeric handle value is opaque: the same numeric value used by different points is not rejected merely because of its representation, while this contract does not select a zero-handle policy for by-name resolution. Missing, extra, duplicate-point, wrong-address, or wrong-descriptor responses reject the candidate. Notify subscriptions likewise return the exact requested point, and every active subscription ID is unique within the connection; this contract does not select a zero-ID policy. The worker publishes the complete handle/subscription maps only after all candidate operations succeed.
A poll batch must have the same cardinality as its requested handle batch and
must preserve point identity at every position before any per-result cache
mutation. A structural mismatch rejects the complete poll response; a later
uniform connection fault projection may update all qualities, but no prefix of
the malformed response becomes accepted data. Each notification sample is
validated independently against both the configured notify point and that
point's currently active subscription ID before that sample mutates the cache.
Unknown points, inactive IDs, and cross-point correlations fail closed. Repeated
valid samples for one active point remain valid and are applied in delivery
order; a later invalid sample does not roll back the raw values stored by
earlier correctly correlated samples, but its connection-level validation
failure may revoke their Good authority. A structurally valid, correctly
correlated point-level non-good or non-finite result remains a per-point quality
result and does not invalidate other points in the batch.
Symbol-derived candidates are valid only when the opaque symbol-version token
is equal before and after upload, handle resolution, and subscription setup.
When the active token changes, the worker immediately makes its old handles,
subscriptions, token, and candidate state unavailable and revokes Good
authority from cached inputs before attempting refresh. Raw cached values may
remain available for diagnostics, but apply_inputs cannot apply them without
current Good quality. Revoking readable authority changes only currently
Good quality and cannot overwrite a read/write point's newer ADS write
pending or explicit non-finite output error. Numeric ordering and monotonicity
are never inferred, including across wraparound. A reconnect request or a
validation failure after a successful transport connect performs best-effort
disconnect, clears local
handles, subscriptions, symbol token, and symbol-check scheduling, and marks
affected input authority stale for transport recovery or error for validation
failure. Replacement state becomes visible atomically only after the complete
candidate passes correlation and token-stability validation. This contract does
not select whether pending outputs are replayed, preserved, or dropped across
reconnect.
Local output intents are generation-tagged. Capturing a finite changed output
atomically replaces the point's queued value with the latest value and reports
ADS write pending. A write completion may acknowledge or update quality only
when it still names the generation that was transmitted; completion for an
older value cannot remove a newer queued value or overwrite its pending/error
quality. The worker validates write-response cardinality before any per-result
acknowledgement or quality commit. A malformed response therefore performs no
partial per-result commit and leaves every transmitted generation pending. A
correlated success atomically acknowledges that generation and reports good
quality; a correlated failure terminally removes that exact generation and
reports explicit ADS write failed: ... quality without falsely acknowledging
success. A later changed finite output creates a new generation normally.
Transport-error and malformed-cardinality connection
projections compare the current complete write-generation state with the
complete snapshot captured atomically with the pending-write batch before
request construction. They must not overwrite a finite pending quality or
non-finite rejection created while the transport call is in flight, including a
change to an output point that was not in the transmitted request; other
input/read authority still becomes stale or error with the connection.
Worker shutdown and drop must wake an idle interval wait promptly, request
best-effort disconnect, publish Disconnected, revoke any remaining Good
input authority, and join the worker thread. Raw input values may remain for
diagnostics, and read/write points retain newer non-good pending or explicit
output-error quality. This local wakeup guarantee does not claim cancellation
of a transport call already in flight. Binding identity, declared runtime type,
remote descriptor, access, retain opt-in, and index byte-size validation remain
separate fail-closed partitions; success in one partition cannot substitute for
another.
The lifecycle/security invariant is that every connection attempt validates all wire authority first and performs no implicit route mutation; after successful validation, replacement, disconnect, and drop attempt best-effort remote release and deterministically tear down transport-owned local resources. The data/value invariant is that supported metadata, handles, sum-up positions, qualities, notifications, and symbol-version equality preserve the remote meaning exactly, while inconsistent or undecodable data fails closed without a fabricated descriptor, success, or value.
ADS server Doctor orchestration and loopback proof¶
ADS server Doctor orchestration and loopback proof are truST runtime product contracts outside IEC 61131-3. The Doctor reports only live, endpoint-correlated proof. A supplied status snapshot cannot make symbol service healthy when the running ADS server's live symbol table is empty; the reported point count and readiness state must reflect the live table used to serve clients.
Independent external-client evidence is verified only when its client kind and
name both contain non-whitespace text and its timestamp is present. Missing or
blank proof content must not produce a passing external-client step or set the
production-evidence external_client_verified flag. Existing production
readiness rules remain unchanged: loopback and pyads evidence are diagnostic,
while production readiness still requires verified TwinCAT evidence.
The loopback client rejects zero symbol and notification handles. A matching notification handle proves delivery only when the sample contains the complete payload requested for that symbol; a truncated matching prefix cannot pass. Every solicited loopback reply must be an ADS response frame whose source and target AMS Net IDs and ports are the exact reverse of the request and whose command and invoke ID match the request before its payload is trusted.
Every passing server Doctor step and every verified evidence field is derived from the running server's live state or from complete, nonblank, endpoint-correlated external/loopback proof; missing, zero, truncated, request-direction, or endpoint-mismatched input fails closed without manufacturing health, verification, or success.
ADS Doctor orchestration and active-device safety¶
The client ADS Doctor emits exactly the ordered required-step set owned by the runtime engine. A production-blocking failure prevents every later direct probe; cancellation marks the first not-yet-started step as cancelled and the remaining steps as blocked. Guarded writes are skipped without blocking when writes were not explicitly enabled, and enabling writes without a complete probe is a blocking configuration failure. Production evidence is attached only after every required step passes, from a runtime-host vantage, with live deployed status available.
Manual Doctor targets must contain a syntactically valid IP address, a non-zero AMS port, and, when supplied, a canonical six-octet AMS Net ID. Those fields are rejected before any ADS wire operation. An explicitly selected symbol must contain non-whitespace text and must occur in the uploaded symbol table; it is never accepted merely because a later transport happens to resolve it.
An active-device shortcut is valid only when its target IP and AMS port, plus the expected AMS Net ID when supplied, identify the requested Doctor endpoint. A mismatched snapshot fails closed without opening a second ADS connection. For a configured route whose host is an IP literal, runtime active-device lookup therefore requires that host IP and AMS port to equal the requested target and, when the request supplies an expected target AMS Net ID, requires that identity to equal the route target AMS Net ID. A route name, target display name, or one matching endpoint component cannot substitute for the complete required match. When both the configured route and request carry a selected local AMS identity, those identities must also be equal. Hostname resolution, omitted-local-identity semantics, and selection among multiple exact matches remain unresolved product decisions and establish no test authority here. Read-only active-device reports derive state only from the supplied live snapshot: a batch-read step passes only when at least one point has good quality with a recorded update timestamp; no points, no timestamped good point, or any degraded point produces a warning. The full Doctor for an overlapping active device remains blocked until the caller explicitly pauses it.
ADS onboarding route artifact and credential safety¶
Automatic ADS route addition is available only over a credential-permitted channel and never for a public or NAT-suspect runtime-host identity. Both the one-shot username and password must contain non-whitespace text before the wire operation is called. Credentials are neither serializable nor present in route plans, generated artifacts, reports, logs, or error detail.
Route-plan and route-removal artifacts are deterministic and credential-free.
XML content escapes every user-controlled field. Generated PowerShell treats
that XML only as data: it transports the UTF-8 XML payload through a non-
executable encoding and decodes it before assigning DocumentFragment.InnerXml;
user-controlled text must never be able to terminate a PowerShell here-string
or introduce a command. PowerShell single-quoted values escape embedded quote
characters, and generated filenames use a filesystem-safe slug. Apply and
remove scripts back up each selected StaticRoutes.xml, match a route by its
exact name, preserve unrelated routes, and report an encoding/BOM change.
Client and server roles retain their distinct operator instructions.
ADS symbol-import artifact safety¶
The server-control symbol-import apply operation writes only inside its selected
project root. Explicit ads.toml, snapshot, and generated-ST destinations must
be non-empty relative paths without parent-directory, root, or platform-prefix
components. The default snapshot destination is available only when the
connection name is a safe single filename component; a connection name must
never become path syntax. The three resolved artifact destinations must be
distinct. A snapshot destination must be a direct *.symbols.json child of
ads/snapshots, because that canonical directory is the authority reloaded by
subsequent imports; accepting an undiscoverable snapshot location is forbidden.
Before writing any import artifact, server control checks an existing generated
ST destination. Byte-identical next content is safe to retain. Different next
content may replace the file only when the existing file still validates
byte-for-byte against the current ads.toml and every canonical cached
snapshot; this permits a legitimate follow-up import while protecting a
drifted or operator-edited file. Missing, malformed, or inconsistent current
authority fails closed. A rejected path or overwrite preflight leaves all three
artifacts unchanged.
These confinement and overwrite rules apply to the server-control authoring
surface; the explicit filesystem paths of the local CLI remain operator-owned.
ADS symbol-import selection and generated identifiers¶
ADS symbol selection is deterministic. A non-empty exact-symbol list has
exclusive precedence over include patterns. Otherwise include patterns are
ASCII-case-insensitive; * matches any sequence, anchored prefixes and suffixes
must be honored across the complete symbol, and a pattern without * performs
the documented substring search. An explicitly empty pattern matches nothing
rather than broadening the import to every uploaded symbol. With neither exact
symbols nor include patterns, all candidates remain selected by default.
Generated local variable names normalize symbol text to lowercase letters,
digits, and single embedded underscores, receive a stable suffix on collision,
and must lex as one Structured Text identifier rather than a keyword or other
token. A normalized keyword receives the ads_ prefix. This follows IEC
61131-3:2013 6.1.2 and Table 2 for identifier form and 6.1.3 for the
case-insensitive prohibition on using keywords as variable names.
Apply remains read-only unless write acknowledgement is explicit. With that
acknowledgement, a remote symbol carrying both Read and Write capabilities maps
to read_write, while a Write-only symbol maps to write; acknowledgement
must not manufacture a remote Read capability that the symbol does not expose.
A cached snapshot is authoritative only for its exact selected connection; a different connection name is rejected before candidate generation or mutation. Cached candidate and group ordering is deterministic. Every candidate reports its proposed local name, access and polling disposition, and selected state without changing the remote symbol's capabilities. A live import is never replaced by cached or synthetic results when wire support is unavailable.
ADS symbol-import apply and merge¶
Core apply requires at least one selected candidate and parses an existing
ads.toml strictly before producing replacement artifacts. It replaces only
the imported connection with the same exact name, preserves and sorts other
connections, pins the selected runtime-host AMS identity, canonicalizes the
merged snapshot set, and generates one deterministic ST interface. Local point
names and their generated quality names are unique across every connection.
Imported ADS scalar descriptors project to the corresponding supported IEC BOOL, signed and unsigned integer, REAL/LREAL, bit-string, and STRING type names defined by Data Types. STRING capacity and every inclusive array lower/upper bound are retained. Poll and notification modes and read, write, or read-write access are serialized without silently changing the selected capability. Malformed current configuration, missing selection, unacknowledged writes, invalid descriptor metadata, or generation mismatch fails before an artifact is returned.
A successful apply then enables the ADS client in the selected project's
runtime.toml: it sets [runtime.ads] enabled = true, config_path =
"ads.toml", and worker_tick_interval_ms = 20 only when that key is absent,
adds the section at the end when it is missing, and preserves every other line,
comment, and section. The offline ads import-symbols command does the same
after it writes its artifacts. Both answers report whether runtime.toml
changed. A missing runtime.toml leaves the artifacts in place and reports that
ADS could not be enabled automatically.
The ads.import_symbols.remove request removes selected imported points from the
project's ads.toml and regenerates the generated ST from the rewritten
configuration and every canonical cached snapshot. Its parameters name the device
host, target_net_id, and ams_port, and the paths to remove. In every
connection whose host, target AMS Net ID, and AMS port equal the device's it
removes the points whose symbol is one of paths; a connection left without
points is removed, and every other line, blank line, and comment is kept. When
that leaves no connection at all, the rewritten file stays valid and the runtime
starts with no ADS bindings. It
answers schema version 1 with applied, matched, removed_count,
restart_required, files_written, and message, writing nothing when no point
matched and refusing without writing when no cached snapshot can regenerate the
interface. It is always write-enabled and therefore always requires Engineer,
like ads.import_symbols.apply.
The generated ADS interface is one deterministic Structured Text source for
all configured connections. It declares the shared ADS_QUALITY enum exactly
once, then each configured point and its <name>_quality companion in stable
connection/point order. Generated identifiers must be valid non-keyword
Structured Text identifiers and unique across point names and generated
quality names. Snapshot and configuration descriptor byte sizes must agree
before generation. STRING capacity and inclusive array bounds are rendered
without widening or flattening their declared type.
Offline validation regenerates the complete expected source from the current configuration and canonical snapshots. It accepts only byte-identical source and reports the first differing line for stale content. A generated source that successfully compiles with its consuming project proves language/toolchain compatibility for that exact artifact; generation or source equality alone does not prove compilation, runtime binding, or live PLC communication.
Once that generated source is compiled into a runtime, its point and quality declarations are ordinary first-class globals. Their declared type and enum variant remain available through control evaluation, debugger globals, historian capture, and the supported OPC UA scalar/enum projection. This cross-surface rule requires runtime metadata and snapshot evidence; source-text presence alone is not proof.
6.8.1b Runtime benchmark measurement and output contract¶
trust-runtime bench validates each workload before measurement: sample,
payload, and dispatch-fanout counts are non-zero; project paths exist; and
synthetic loss and reorder rates are within the closed interval from zero to
one. Requested warmup cycles are executed before measured project cycles and
are excluded from latency samples.
For T0, mesh, and dispatch workloads, --payload-bytes is the exact payload
length exercised by every applicable measured path. Implementations must not
silently cap large requested payloads. Mesh pub/sub and query/reply both use
the requested length; dispatch embeds the requested length in its mapped
control payload. Their reports expose the effective payload length so evidence
cannot claim one workload size while measuring another.
Latency summaries use sorted nanosecond samples and nearest-rank p50, p95, and p99 selection. Histogram classification rounds each sample up to a whole microsecond and retains an overflow bucket. Jitter contains the absolute delta between consecutive observations, so fewer than two observations produce no invented jitter sample. Synthetic loss and reorder decisions use a fixed seed for reproducible workload selection.
JSON output serializes the benchmark kind and its typed report; table output
renders the same contract fields. Project reports separate measured cycles,
configured cycle budget and overruns, throughput, and requested watched
globals. A missing watched global is explicit JSON
null, not an omitted or fabricated value.
Every benchmark subcommand defaults to table output when --output is omitted;
an explicit --output json selects JSON without changing the selected workload
parameters.
PLCopen CLI surface contract¶
trust-runtime plcopen export accepts an optional project, optional output
path, optional export target, and JSON-output selection. An omitted export
target selects generic. The canonical targets are generic, ab, siemens,
and schneider; the documented Allen-Bradley/Rockwell, Siemens TIA, and
Schneider EcoStruxure aliases map to their corresponding canonical targets.
trust-runtime plcopen import requires an input XML path and accepts an
optional project and JSON-output selection. Parsing preserves supplied paths
and choices without performing import or export work.
6.8.1c Runtime project-check contract¶
trust-runtime check validates a project without writing program.tpi or any
other build output. An explicit --project selects the
project root; otherwise the command uses bundle auto-detection and falls back
to the current directory. A relative --sources override is resolved from the
project root, while the default source root is <project>/src.
The command validates required runtime.toml and io.toml files plus optional
ads.toml and opcua_client.toml files, inspects the complete source and local
dependency layout, and checks valid layouts with the build's front end and its
generator for this computer's CPU, without the toolchain (specification 44
§4.1). Independent configuration and compile findings are accumulated in one
response. A configuration failure does not suppress an otherwise possible
check, and a compile failure does not discard the source and dependency
identity already obtained from successful layout inspection.
--json and --ci emit response version 1. The response identifies the
command and project; exposes ok/status, error and warning counts, stable
issue codes and optional file locations; and reports the exact source count,
source paths, dependency roots, and resolved dependency names. source_count
equals the number of reported source
paths. ok is true exactly when the error count is zero. Human output is
derived from the same response and reports either the successful source list
or every accumulated issue.
Success exits 0. Any configuration or source-layout error exits with the
invalid-configuration class 10, including a response that also contains a
compile error. A compile-only failure exits with the build-failure class 11.
Both successful and failed checks leave an existing program.tpi untouched
and never create one.
6.8.1d Runtime build and CI failure contract¶
trust-runtime build compiles the selected project into the program image of
this computer's CPU and writes it as <project>/program.tpi, with its
deployment record <project>/deployment-record.bin (specification 44 §4.1,
NX-15); with --target <device or CPU> it writes that target's image into the
project's build folder as build/program.<triple>.tpi, with
build/deployment-record.<triple>.bin. An explicit --project selects the
project root; otherwise ordinary bundle discovery is used and the current
directory is the final fallback. The default source root is <project>/src. A
relative --sources override is resolved from the project root, including when
the command is launched from another working directory. Local package
dependencies are resolved and compiled with the selected project sources. The
build installs the toolchain on the first build that needs it (specification 44
§4.6). The project's [build] debug chooses a debug or a release image
(specification 44 §13.2), and the image is signed with the project's key or the
user's own before its record names its bytes (specification 44 §5.11, §10.1).
--emit-source keeps the generated Rust crate in the build folder. A source set
that declares OpenOT data also gets its openot-definition.json.
Compilation completes before the image is opened for writing. A source, dependency, or compile failure therefore neither creates the image nor replaces bytes from an earlier successful build.
Every build writes build/build.json: version 1, command build, status ok,
the selected project, source_count, the complete sources list with the
dependency sources, the dependency_roots and the resolved_dependencies
names, the target triple, debug, the image path and image_bytes, the
signer key, the record path, the memory_figure (areas, stack,
bookkeeping, retain_capture, total, call_level) and the flash parts
(code, rodata, init) of specification 44 §10.4; source_count equals the
number of reported source paths, and --ci prints the same object. Human mode
reports the written image, whether it is a debug or a release image, its bytes
with its code, read-only data and starting memory, the memory figure with its
parts, the public key that signed it, and the source count with at most the
first five collected paths and the number omitted.
An image runs the one resource it was built from (specification 44 NX-03), so
a build refuses a project whose runtime.toml [resource] name is not the
RESOURCE its CONFIGURATION declares, compared without regard to case:
resource '<name>' of runtime.toml is not the RESOURCE the program declares
('<declared>'). A program without a CONFIGURATION runs under the name the
project gives it.
CI failures use stable process classes: invalid project or configuration input
is 10, build or compile failure is 11, test assertion or runtime-test failure is
12, timeout is 13, and an unclassified internal failure is 20. A recognized
failure class is preserved regardless of command context. Timeout takes
precedence when a message contains multiple recognized classes; otherwise test,
build, and configuration classes are considered in that order. Only an
unclassified message uses the parsed command as a fallback: build and check
map to 11, test to 12, validate to 10, and other or absent commands to 20.
This CLI and process-exit policy is a truST product contract rather than IEC
61131-3 language behavior.
A failing command writes Error: <message> to stderr. For a person at the
command line, a recognised failure adds a Tip: naming the command that
resolves it, such as trust-runtime wizard --path <project-folder> for an
invalid project folder. A command run with --json is read by a program, which
words its own guidance (the VS Code extension names its own commands), so its
error carries no tip; the exit code is the same as without --json.
6.8.1e Standalone Web IDE contract¶
The built-in standalone Web IDE is a local authoring surface over the selected project root. Its shell is served from bundled, content-hashed local assets and does not require a CDN. Project selection is explicit: opening another approved project updates the active root, while a request outside the approved base is rejected before filesystem access.
An IDE session has a stable Viewer or Editor role. Viewer sessions are read-only; Editor sessions may mutate project files. Successful activity renews the sliding idle deadline, an idle session expires, and reaching the fixed session limit evicts the oldest inactive session rather than an active, recently renewed one. Pairing and session tokens are required by the endpoint contract and are never accepted as workspace paths or file contents.
All source paths are relative to the active project root. Absolute paths, parent traversal, escaping symbolic resolution, malformed include/exclude globs, and oversized JSON or file content reject before mutation. File writes use optimistic version authority: the caller supplies the expected version, a successful write increments it, and a stale expected version returns a conflict with the current version. Create, rename, and delete preserve project containment, report collisions, and append the successful mutation to the filesystem audit log. Tree, file-list, and workspace-search results use stable relative paths and honor validated include/exclude globs.
Diagnostics, hover, completion, and formatting use the same Structured Text analysis and formatting contracts as the language service. The format endpoint returns the affected path, changed flag, and complete formatted content. Build, test, and validate endpoints start an asynchronous job, return its job ID, and retain terminal output plus parsed source locations. Health reports active sessions, tracked documents, and accumulated frontend telemetry. Frontend telemetry updates the same health snapshot; collaborative-presence functionality that is not implemented is reported explicitly as out of scope, not as an active collaboration session.
Workspace path normalization trims outer whitespace, removes . components,
uses / in returned paths, and rejects an empty non-root path, absolute or
rooted input, every .. component, and every component beginning with ..
Source paths additionally end in .st case-insensitively. File and tree
enumeration omit hidden entries, return project-relative /-separated paths,
and sort tree siblings by name. Source enumeration includes only .st files
and does not follow a path outside the selected root.
Reads decode UTF-8 and admit a file whose exact byte length equals the
configured limit; one byte over returns too_large. Fingerprints contain exact
byte length and the filesystem modification time in Unix-epoch milliseconds.
Glob inputs are trimmed; absent or blank input disables that filter, malformed
syntax returns invalid_input, and a valid pattern is preserved as the
matching authority.
IDE positions count Unicode scalar values within zero-based lines. A position beyond its line clamps to that line's byte end and a line beyond the document clamps to document end. Compiler text offsets converted back to IDE positions must be valid UTF-8 boundaries. Rename edits are applied from greatest start offset to least so earlier offsets remain stable. Every range is ordered, in-bounds, on UTF-8 boundaries, and non-overlapping; invalid edit sets reject without returning partially edited text.
Completion prefix extraction considers ASCII letters, digits, and _ in the
current line before the cursor. In-scope declaration fallback recognizes valid
ASCII identifiers from POU/type/class declarations and variable declaration
left-hand sides, ignores comment-only lines, ranks prefix-matching symbols
before keywords and non-prefix matches, deduplicates labels
ASCII-case-insensitively, then applies the requested result limit.
The formatter is deterministic, uses two spaces per nesting level, removes
trailing horizontal whitespace, preserves blank lines and comment text, and
returns one terminal newline for every nonempty input. POU, declaration,
conditional, loop, case, repeat, action, transition, method, property, class,
interface, resource, and configuration openers indent their bodies. Their
corresponding END_*, ELSE, ELSIF, and UNTIL lines dedent before
rendering; branch continuations then indent their following body.
Session removal is atomic across session authority, frontend telemetry, analysis cache, and every document's open-handle set. Expiry removes entries whose deadline is less than or equal to the current second and leaves later deadlines intact. Generated session tokens are URL-safe unpadded encodings of 32 random bytes.
The standalone IDE resource contract is:
- one source apply must remain below 250 ms, and the repeated local in-process average below 40 ms;
- the HTTP apply path must remain below 250 ms maximum and 50 ms average in the focused reference fixture;
- reference p95 bounds are 2.5 s for boot-to-ready, 150 ms for completion, 150 ms for hover, 300 ms for diagnostics, and 400 ms for workspace search;
- the maximum completion request over the 2,000-line typing fixture is 800 ms; and
- content above the advertised maximum file size returns HTTP 413 or the
equivalent typed
too_largeerror before writing.
These are focused reference-environment budgets. They are regression gates for the shipped authoring path on the x86_64 CI reference environment, not universal real-time guarantees for arbitrary hardware or projects.
Unified Web IDE shell contract¶
The standalone Web IDE uses one unified product shell. /, /ui, /ide, and
supported tab deep links resolve to that shell, whose complete bundled module
set includes the workspace, editor, online, hardware, settings, observability,
logs, debug, and command surfaces. The shell preserves explicit tab panels,
status-bar state, compact toolbar and overflow actions, hidden-panel CSS
semantics, and ARIA-selected tab state.
Online control defaults to the same runtime web origin and normalizes both wrapped and direct API payloads. I/O configuration reads and writes follow the active workspace. The hardware surface preserves the MQTT connectivity probe, Runtime Cloud topology, and realtime-link transport projection; settings preserve the documented realtime-link fields. Removed legacy fleet mutation routes remain not found rather than forwarding to a current Runtime Cloud operation.
Unified Web IDE hardware and settings interaction contract¶
The Hardware and Settings tabs are rendered operator surfaces, not
source-presence claims. Their tab buttons use the tab/tabpanel ARIA
relationship, expose exactly one selected tab, update the /ide/<tab> route,
and preserve the selected tab across a same-origin reload. Ctrl+2 and
Ctrl+3 select Hardware and Settings when focus is outside a code editor.
Changing tabs does not itself write runtime.toml, io.toml, or
simulation.toml.
Hardware loads the active project's current runtime and I/O snapshots before
presenting them as configured state. A planned or offline runtime remains
visibly offline; the canvas, cards, or presence of configuration must not
invent a live connection. The component palette exposes its categories as
expandable buttons with accurate aria-expanded state. Hydrated I/O and
runtime communication drivers appear as labeled cards. Expanding and
collapsing the driver region updates both the visible region and the
controlling button's aria-expanded value.
Hardware-tab activation and route changes remain browser-responsive while the communication graph is laid out. Canvas relayout requests made before the next animation frame coalesce into one pending frame. Replacing or deactivating that request cancels both the pending frame and its delayed resize/fit timers. The latest request schedules one fit pass after 260 milliseconds, so project hydration cannot overlap or accumulate parallel resize/fit sequences.
Every driver-card configuration action carries one exact settings key and category. Activating it selects Settings, updates the route, renders the owning category, and focuses or exposes the corresponding field. Loopback and simulated drivers share the documented simulated-I/O settings rather than inventing a separate loopback schema. Runtime and endpoint actions preserve the selected runtime scope when routing to Settings. Merely opening a configuration action does not save a value.
Settings renders all categories plus a text filter. Filtering is case-insensitive over labels, keys, category names, and group text; it reports the visible and total field counts, hides nonmatching editable fields, and offers a clear action that restores the category. A no-match query displays an explicit empty result rather than stale fields. Category selection clears neither entered values nor the selected runtime. Credential-bearing password and token fields render with password input semantics. The Advanced category labels runtime state as read-only and keeps import, export, reset, and direct-TOML actions distinct from ordinary field edits.
Hardware and Settings use the shared ide-runtime-selection-changed event.
An accepted runtime-scope change on either surface is reflected by the other
without a write or feedback loop. Runtime-, I/O-, and simulation-backed fields
retain their distinct persistence targets. Structured JSON fields are parsed
and normalized before any write. Saves are serialized in user-action order,
use the current revision/version precondition, and dispatch the matching
runtime- or I/O-config-updated event only after a successful write. A parse,
authorization, conflict, transport, or backend failure is shown as a failure
and must not be reported as saved.
Automated acceptance must exercise these behaviors in the shipped browser surface. Module-source string checks may lock packaging details but do not prove rendering, interaction, routing, focus, accessibility state, or cross-tab synchronization. Final acceptance also retains a real rendered screenshot or recording for the Hardware and Settings journeys.
Standalone configuration UI workspace contract¶
The standalone configuration UI projects the current product mode, planned workspace runtimes, topology, Runtime Cloud configuration and rollout state, and the active runtime's I/O configuration from one selected workspace. Planned but disconnected runtimes remain visibly offline/degraded and their links failed; they are not reported as live because they exist on disk.
Runtime configuration writes use an expected source revision or digest. A stale
precondition returns conflict without changing the file; a current write
updates that runtime's runtime.toml. Structured Text writes remain inside the
selected runtime and the validation endpoint reports valid or invalid source
without treating invalid source as a successful validation.
Runtime create and delete operate on one named workspace runtime and are reflected in subsequent project-state and lifecycle queries. Lifecycle status identifies the workspace runtime and whether it is managed. Live-target profiles may be listed, upserted, selected, connected, queried, and removed; an unreachable target remains disconnected with its last error and active target visible.
Workspace runtime IDs are trimmed, nonempty, lowercased ASCII identifiers
containing only letters, digits, -, and _. Resolution is exact after
normalization; duplicate configured resource names reject the workspace.
Workspace discovery admits runtime.toml at the selected root and in direct
child directories only, sorts runtimes by normalized ID, and rejects an empty
workspace. A generated runtime contains validated runtime.toml, io.toml,
and src/main.st files beneath a new child named by that ID. Creation never
reuses an existing ID or filesystem path. Deletion cannot remove the last
runtime, the workspace root, or any path outside the workspace root.
Host-group input is trimmed and lowercased. ASCII letters, digits, -, and _
are preserved; every other character becomes -; an empty result is absent.
The generated runtime template uses default-host when no nonempty group is
supplied and binds its resource, service, Unix control socket, local web
listener, local discovery, peer mesh, and Dev Runtime Cloud identity to the
normalized runtime.
Configuration revisions are lowercase SHA-256 over the exact UTF-8 file text.
An expected revision is checked before validation or replacement. A mismatch
returns the current revision as a conflict and leaves the file unchanged.
Accepted writes validate the complete candidate and replace the destination
through a sibling temporary file. Structured Text paths are relative to
src/, remain contained there, and end in .st case-insensitively; absolute
paths, parent traversal, empty paths, and other extensions reject.
Live targets are trimmed HTTP(S) origins. A missing scheme receives http://;
an explicit scheme is lowercase http:// or https://. The target contains a
nonempty authority and no user-info, path, query, or fragment; one optional
terminal slash is removed. Profiles are keyed by normalized target and listed
in key order. A blank label defaults to the normalized target. Snapshots expose
the target and label, connection state, active target, last error, and update
time, but never the stored credential token. Removing the active profile also
clears its token, connection state, last error, and cached Runtime Cloud state.
Lifecycle probes accept configured tcp://<socket-address> and, on Unix,
nonempty unix://<path> endpoints. Empty, malformed, unsupported, or
unreachable endpoints are offline. Status distinguishes an externally running
runtime from a child managed by this configuration UI; stop and restart do not
claim ownership of or terminate an externally managed process.
Every configuration-UI response is JSON with the requested HTTP status.
Structured errors retain the stable error_code, message, ordered field-error
list, and optional conflict version without converting an error into ok:
true.
6.8.2 Versioned deployment and rollback¶
trust-runtime deploy validates the source bundle before installation and the
copied bundle before changing deployment pointers. A custom deployment label
must be one non-empty filesystem component: absolute paths, ./.., path
separators, and platform prefixes are rejected. Automatically generated labels
must be unique across consecutive deployments from one process, including
deployments started within the same second. A label that current or previous
names is refused with deployment already exists. A bundles/<label> that
neither pointer names is the leftover of an interrupted or refused stage: it
blocks nothing, and it is removed before a deployment under that label is
staged (specification 34 §11.2).
When deployment signature policy is enabled, validation occurs before any
bundle file or deployment pointer is changed. A payload signed by a currently
trusted, unexpired key is accepted. A tampered payload, unknown key, expired
key, malformed signature, or policy mismatch rejects the deployment without a
partial install. The policy in force is the one in the project's installed
runtime.toml ([runtime.deploy] require_signed and keyring_path), never the
configuration the deployment carries: a deployment that changes the policy is
checked against the policy it replaces, so it can neither turn the requirement
off nor name a keyring of its own. Only a project with no installed
runtime.toml takes the policy from the deployment's own configuration. Failure output may identify the key ID and failure class but
must never echo key material or another configured secret. Source paths with an
optional leading src/ normalize to the same contained bundle destination;
normalization never permits an absolute path, parent traversal, or platform
prefix.
A deployment through the web interface (POST /api/deploy, Admin) carries any of
runtime_toml, io_toml, the program image program_tpi_b64 (base64 of
program.tpi, specification 44 §8.8) and sources; a request that carries the
bytecode engine's program_stbc_b64 is refused (specification 44 §15). It installs into
the running runtime's project folder, so it follows the rule of program.install
(specification 34 §11.2): it is accepted only while the resource is Stopped.
Otherwise its reply is {"ok": false, "error": <message>, "error_code":
"resource_not_running"}, with the message and the code that program.install
gives for the same state, and a hung scan thread is refused as program.install
refuses it. A refused deployment writes nothing, and the refusal comes after the
authorization and before the deployment's signature or any file is read. A
deployment that asks for a restart has that restart sent once the deployment
is installed, which starts the new program (specification 34 STATE-08). A
rollback through the web interface (POST /api/rollback, Admin) changes which
program the runtime starts next, so it follows the same rule with the same
refusal, and a refused rollback changes no link.
The deployment root contains bundles/, current, and optionally previous.
The pointer targets are resolved relative to the link location when an older
relative link is read; newly written links target the canonical absolute bundle
directory so a relative --root cannot create a broken pointer. Replacing a
pointer must also replace a dangling symlink instead of treating it as absent.
An ordinary file or directory at a pointer path is an error and is not deleted
or overwritten.
An existing pointer is eligible for retention or rollback only when it resolves
to a direct child directory of that deployment root's bundles/; a pointer to
another filesystem location is rejected rather than adopted.
Both deployment pointer slots are inspected before a new bundle is installed.
If either slot is occupied by an ordinary file or directory, deployment fails
without installing the candidate, changing either pointer, or publishing its
change summary.
Enabled ADS and OPC UA client configuration is self-contained in the installed bundle. Each configured sidecar path must be a normalized relative path contained beneath the source bundle, and the file is copied to the same relative path in the installed bundle. Absolute paths, parent traversal, platform prefixes, and symbolic links in any copied bundle path are rejected; deployment never follows a symbolic link while constructing the installed bundle.
After a successful deployment, current identifies the newly validated bundle
and previous identifies the bundle that was current immediately before the
deployment. Pruning retains exactly those existing bundle directories and
removes older directories only from the deployment root's own bundles/
directory. It must not delete the new rollback target. rollback swaps the two
valid pointers without copying or rebuilding either bundle.
Before deployment pointers change, the command constructs a deterministic
change summary that identifies both the bundle being deployed and the previous
bundle, when one exists. It publishes that summary only for a deployment whose
pointer transition succeeds. The runtime.toml and io.toml status is based
on the complete file content, so a change outside the selected
operator-friendly detail bullets must still be reported as an update rather
than as unchanged. Safe-state comparison uses the structured I/O address and
value, not a potentially lossy display form. Source comparison recursively
covers .st and .pou files case-insensitively beneath src/, with stable
relative-path ordering. A source directory traversal or file-read failure
aborts summary construction and the deployment; it is not converted into an
empty source set.
deployments/<label>.txt and deployments/last.txt contain the last successful
deploy command's change summary. They are not deployment pointers and rollback
does not rewrite them. The current symlink is the authoritative active-bundle
identifier before and after rollback.
6.8.3 Managed local fleet scaffolding¶
trust-runtime fleet runtime add creates one managed local runtime project
beneath the selected fleet root and registers it in fleet.toml. Runtime names
use the CLI's portable name alphabet and must be unique in a manifest; hand-edited
manifests are subject to the same validation before list or lifecycle actions.
Generated entries use the portable runtime name as a relative project path
beneath the fleet root. A hand-edited nonempty project path is resolved against
the fleet root when relative and retained as a local path when absolute; this
path flexibility does not relax name, endpoint, or port validation.
Every control endpoint must parse under the runtime control-endpoint contract,
every web port must be nonzero, and TCP control ports and web ports must be
unique across roles and runtime entries. Ambiguous or malformed manifests fail
before filesystem or lifecycle mutation.
Generated control and web listeners use distinct available loopback ports. The generated control credential is 32 bytes from the operating-system secure random source and is encoded without padding. If secure randomness is unavailable, scaffolding fails; it must not substitute timestamps, process IDs, zero-filled bytes, or another predictable credential. Resource names are derived deterministically from the portable runtime name, with a nonnumeric fallback/prefix. Generated runtime and I/O configuration is parsed and validated before the manifest is updated, and the source scaffold is emitted deterministically.
Fleet lifecycle status is control-endpoint evidence, not PID-file inference.
Failure to connect means stopped; once an endpoint accepts a connection, an
authentication rejection, malformed envelope, oversized response, mismatched
response ID, or missing Boolean ok is a command error and must not be
flattened to stopped. Failure to read or parse the managed project's control
credential is likewise an error. The PID file is advisory process metadata.
fleet runtime stop ends the managed process, so it sends shutdown with
{"exit": true}; a plain shutdown would leave the process running with its
program Stopped (specification 34 STATE-08). fleet runtime start of a runtime
whose control endpoint answers status with the state stopped starts its
program with restart cold, the mode of a new process, and reports the
runtime started once its state is running; it does not report that runtime as
already running.
A successful shutdown request prepares and writes its complete correlated
control response before it signals the resource stop. The stop signal remains
part of the accepted request and is applied immediately after the response
write attempt; process teardown must not race the operator-visible
acknowledgement off the control connection.
After a valid shutdown acknowledgement, fleet stop confirmation probes may race
process teardown. A connection reset, broken pipe, aborted connection, empty
EOF, or transport read/write timeout is inconclusive during this confirmation
only: it consumes the existing bounded polling budget without resending
shutdown. A subsequent connection failure confirms stopped and removes the
advisory PID; an accepted status response still means reachable. If confirmation
exhausts its budget, return stopping and retain the PID rather than claiming
success. Authentication failures, malformed or oversized responses, wrong IDs,
and configuration errors remain errors, including after acknowledgement.
Ordinary status and the initial stop probe retain their existing strict error
behavior. A transport interruption alone never proves a completed stop.
status and logs are read-only and do not create .trust-runtime state when
it is absent. Log tailing retains only the requested final lines in memory
rather than loading the complete log file. A start operation may create its
lifecycle directory and open the child log as spawn setup, but it publishes the
advisory PID only after spawning the process. If PID persistence fails, it
terminates and reaps that child, and if the child exits before the control
endpoint becomes ready, start fails and removes the stale PID file.
Managed-fleet scaffolding always emits a loopback TCP control endpoint because
the manifest and lifecycle commands need one portable explicit endpoint. This
is distinct from the generic runtime default, which uses unix:// on Unix-like
platforms; a generated Unix socket is created with restrictive permissions
(0600) to prevent accidental exposure.
Managed-fleet projections are operator-facing and secret-free. ADS allowed
clients are rendered as human-readable identity/source summaries rather than
editable raw pin objects. Host names are trimmed, lose a trailing DNS dot, and
prefer the operating-system host name; a blank source is ignored and the stable
literal local-host is used only when no host authority exists.
6.9 Debug Attach (Production)¶
Attach debugging is optional in production deployments but must be supported by the runtime when enabled:
- Attach must not restart or reload the runtime.
- Attach must observe the current state (running/paused/faulted).
- Detach must not alter runtime execution.
- Debug hooks must be side-effect-free when disabled.
- Attach is gated by
runtime.control.debug_enabled. When disabled, debug control requests are rejected. The default is disabled in production mode (seeruntime.control.mode). runtime.control.modedefaults toproductionand can be set todebugfor development workflows;runtime.control.debug_enabledoverrides the mode when explicitly set.step_in,step_overandstep_outact only while the debugger holds the scan: at a breakpoint, after a debug-modepause, or after a completed step. Otherwise they are rejected with a stable error and arm nothing. In production modepauseandresumeact on the resource scheduler, so a step after a productionpauseis rejected; an attached debug adapter can still step from a breakpoint. In either mode,resumealso releases any debugger hold, so the scan returns to cyclic execution. While a debugger holds the scan,statusreports the hold rather thanrunning.- A
pausedorrunningreply means the resource accepted the request; the scheduler acts on it at its next command boundary, andstatusreports the state actually reached. On a resource that is faulted or stopped, or whose scheduler no longer accepts commands, both requests are rejected with a stable error that names the resource state, and they change nothing: a faulted resource resumes only throughrestart, which runs the configured warm or cold initialization first (specification 34 STATE-06). restarttakes the modecoldorwarm. A new trust-runtime process startscoldunless it is launched with--restart warm(trust-runtime runandtrust-runtime play), so a tool that starts a stopped program withrestartsendscold, the mode of a new process; Devices & Connections does so for a runtime that keeps control aftershutdown(specification 25 §5.1).shutdownstops the program by the ordinary stop path below and leaves the runtime running in the Stopped state on every runtime: the control server, the web UI and the other services stay up,statusreports the statestopped, andrestartstarts the installed program again (specification 34 STATE-08).shutdownwith{"exit": true}, the explicit exit, also ends the process once its reply is written.paramsmay be absent or an object whose only member isexit, a Boolean: another shape is refused withinvalid params: parameters must be an object,invalid params: unknown parameter field,invalid params: parameter fields must not be nullorinvalid params: exit must be a boolean, and stops nothing. Ashutdownof a resource that is already stopped, or that a fault has stopped, stops nothing more and repliesstopping.- Restart requests that arrive before the resource has taken the previous one coalesce into a single restart, and a cold request takes precedence over a warm one: a pending cold restart is never downgraded by a later warm request, and a later cold request upgrades a pending warm one. Each accepted request is answered once.
- The resource command queue holds at most 1,024 commands. A request that finds
it full is rejected with the stable code
resource_busyand is not queued, so a burst of requests cannot grow the runtime's memory without bound. - Between two cycles the scan thread serves the commands that the queue holds when it begins to serve them, at most 1,024. A command queued while they are served waits for the next boundary, so that clients that send a new request as soon as one is answered delay the next cycle by at most one queue of commands and never hold the cycles back.
Operations while the debugger holds the scan:
- A debugger hold (a breakpoint, a debug-mode pause or a completed step) takes
effect at the next statement boundary. A scan that starts while a hold is
already requested first runs the boundary of specification 10 §4.7, step 2: it
applies its queued writes and forces and samples its inputs. It then waits
before the first statement of its first program. While it waits, no program
runs, no output is committed, no retain checkpoint is saved and the cycle number
does not advance; a debugger that inspects the waiting scan sees the boundary
writes already applied. Releasing the hold continues that same scan, which is
neither restarted nor repeated.
- A hold is debugger state, not resource state, and it changes no value. Checked
writes, queued writes, forces, releases and retain saves between scans are
accepted and behave as without a hold (section 6.9.2; specification 34
HOST-09). A write or a force queued while a scan waits in the hold is applied
when the hold ends, before the held statement runs, as at a scan's start
(specification 44 §13.2), so a value changed at a breakpoint is the one the
continued scan reads.
- A restart or an online change that commits between scans (called by the
embedding host, or taken by the scan loop between cycles) is a clearing boundary
as always (section 6.9.2) and leaves the hold in place: a paused session stays
paused after a reload (specification 13), so the next scan waits again at its
first statement. One whose preparation fails changes neither the controller nor
the hold (section 6.7.1).
- The scan thread applies resource commands and takes restart requests only
between scans, so while it waits inside a hold they wait with it. A restart
request is accepted (restart queued) and taken after the hold is released and
the held scan has completed; it is never applied to the suspended scan. A reload
that the resource has not started to apply within five seconds is withdrawn
(section 6.7.1). resume releases the hold (above).
- A stop request is independent of the hold (specification 13, "Pause"). The
control shutdown, SIGINT and SIGTERM all request the scheduler's stop, and a
stop ends the hold and keeps the debugger from holding the scan again until the
program starts again, so a pause, a step or a breakpoint cannot delay the stop
and no resume is needed first. The scan that the hold suspended runs to its end, as
any scan does that a stop request finds under way, with no further pause, step
or breakpoint. The resource then takes its ordinary stop path (section 6.6 and
the retain-store rules of section 6.7): the configured stop outputs are applied,
the retained values are saved, and the resource ends Stopped (Faulted when
the stop outputs are unconfirmed or the save fails). shutdown then leaves the
runtime running in that state, and SIGINT, SIGTERM and the explicit exit also
end the process. A program that starts later on the same debugger, after a
restart or in a new process, can be held again.
Hold duration (owner, 18 October 2026). A debugger hold lasts until a continue or a stop, on every
runtime, trust-runtime and a chip alike, however long the operator waits. While it lasts, the held
scan runs no program and the rules above apply unchanged. Neither a detach, an attach, a configuration
change, a command waiting in the queue, a task deadline that passes, an elapsed timeout nor a lost
connection releases it, and the cycle watchdog is suspended with the hold as section 6.6 states.
resume is the one release a debugger asks for, and the explicit stop above is the one end that is
not a continue, so a breakpoint, pause or step never delays a stop. On a chip the hold loop serves the
control connection and feeds the hardware watchdog in every pass, so a long hold neither resets the
device nor loses a queued request (specification 34 STATE-07, TIME-04).
These hold rules are a truST host/debugger contract outside the IEC language execution model, not an IEC deviation.
6.9.1 Managed debug floating-point ingress¶
In a managed local debug session, stIoWrite and stIoForce interpret values
for declared REAL and LREAL addresses as semantic decimal values. They
reject NaN, positive infinity, negative infinity, overflow to a non-finite
declared value, and raw integer encodings of non-finite IEEE bit patterns
before queueing, forcing, or changing the process image. A rejected request
returns a failed DAP response and preserves both the previous process-image
value and the previous force state. Attach-mode requests remain governed by
the separate runtime control endpoint contract.
6.9.2 Debug mutation lifecycle¶
Debug writes are one-shot requests. An accepted write is applied at the next scan boundary and is then consumed. A force is applied at scan boundaries until the matching release request or a clearing lifecycle boundary: at the start of the scan, after its writes and inputs, and again after the scan's last task, before the outputs are sampled (specification 10 §4.7, steps 2 and 6). A forced variable therefore reads its forced value between scans, and a forced variable that lies at an output address reaches the output, whatever the program wrote to it during the scan. Release removes the force without writing a replacement value; normal program and I/O evaluation determine the value at the next scan.
A write to a variable that reaches the queue while another write to the same
target is still queued replaces it in place: the earlier value is never applied,
even when the later one is refused at the boundary, and the target keeps its
place in arrival order (section 6.8.1). Replacing a queued write is never refused
by the queue bound. Variable writes to different targets apply in arrival order
at the boundary and before the forces of that boundary (specification 10 §4.7,
step 2), so a force on the same target wins. A write is checked when the
boundary applies it, not when the queue accepts it (specification 34 HOST-09): a
value its target cannot take, for example one outside a subrange, is dropped
alone with a write_rejected event, leaves the target unchanged and does not
fault the PLC. This replacement rule is for variable targets; each io.write to
an address stays a separate ordered request (section 6.8.1).
Pause and resume preserve queued writes and active forces because neither operation creates a new runtime lifecycle. A non-terminating debugger detach or transport disconnect also preserves them: detach must not alter runtime execution, and a later authorized client must observe the existing force state. An authorization change governs subsequent write, force, and release requests; it does not silently mutate an already accepted force.
Deliberate resource stop, fault handling, and the commit of every warm or cold restart or online change are clearing boundaries. Before safe-state handling or restarted execution can proceed, the runtime clears queued debug writes and active variable and I/O forces. Safe-state output therefore has precedence over debug forcing, and a request accepted before a clearing boundary cannot be replayed into the next runtime lifecycle. A restart or online change whose preparation fails never reaches its commit, so it is not a clearing boundary: queued writes and forces stay as they were (specification 10 §4.8 and the online-change preparation rule in section 6.7.1).
A write or force that names an instance by its identity, such as an HMI write to a
program member or a debugger write to a function-block field, keeps the
installation generation in which that identity was resolved (specification 34
§11.3). An identity given as a number in a control request, as in
instance:<id>:<name>, belongs to the generation installed when the request is
accepted. A request accepted after a clearing boundary can still carry an
identity resolved before it, for example from the observation of the previous
lifecycle. Its boundary then rejects it as ReferenceLifetime
(runtime_reference_lifetime) with a write_rejected event, does not fault the
PLC, and leaves the instance that now has that identity unchanged; a rejected
force is also removed. A request that names its target by name, such as
global:<name>, is bound to that name at its boundary.
While the resource is faulted no scan boundary occurs, so nothing applies a
queued write or a force, and a scan request is refused before it touches either
(specification 10 §4.7, step 1). The requests of this section are still accepted
and recorded as at any other time, because a fault does not make an engineering
request invalid: writes and forces queue, and a release removes its force. What
was recorded while the resource was faulted is cleared by the commit of the
recovery (a restart or an online change, section 6.6), so it is never replayed
into the recovered lifecycle; a recovery that fails commits nothing and leaves it
pending (section 6.7.1). The tooling reset clear_fault clears only the fault
state and is not a clearing boundary, so what was recorded while faulted is still
pending when a scan follows it. A checked write by the embedding host
(specification 34 HOST-09) is an operation between scans that does not depend on
the resource state: while the resource is faulted it is accepted or refused by its
ordinary rules, changes only its destination, and neither clears the fault nor
resumes execution. Writes that reach the scan loop as resource commands (mesh
updates) are discarded while the resource is faulted.
6.9.3 Simulation coupling thresholds¶
The file-backed simulation.toml loader rejects NaN and positive or negative
infinity in an optional [[couplings]].threshold before returning or
activating the configuration. It does not normalize, clamp, or substitute a
threshold. This contract does not govern programmatic coupling-rule
construction or source-value admission.
6.9.4 Simulation model execution¶
An explicit [simulation].enabled value controls activation. When that field
is omitted, the loader enables simulation if the file contains at least one
coupling or disturbance, or enabled physics with at least one joint. An
explicit enabled = false remains authoritative even when model entries are
present. The loader preserves the configured seed and positive time scale,
sorts file-backed disturbances by due time, and preserves the declared
coupling and physics model. The seed is part of replay identity; changing it is
not required to alter a model that contains no stochastic element.
Post-cycle simulation observes the completed output image. A changed coupling
value is queued for its configured delay, with source >= threshold selecting
on_true and a lower value selecting on_false. Physics also queues encoder
feedback after stepping. Neither path writes the input image during
post-cycle processing. Pre-cycle processing applies due effects in due-time
then insertion order, so a zero-delay post-cycle result first becomes visible
at the following pre-cycle boundary.
A scripted fault disturbance in an accepted file-backed or programmatically
constructed model does nothing before its due time. At or after that time it
faults the runtime and returns a visible simulation error.
Accelerating the scheduler clock does not reinterpret scaled model time as
active watchdog execution time. An explicitly stopped, non-overrunning
accelerated resource reaches Stopped without a terminal error; genuine active
execution overruns remain governed by the separate watchdog contract.
The in-tree physics backend accepts revolute joints with output-bit enable sources and input-word feedback targets. A physics feedback target that duplicates a coupling target or an earlier physics target is rejected before activation. Each enabled joint advances by its fixed step, clamps to its configured limits, and publishes the rounded product of clamped angle and counts-per-radian, bounded to the unsigned 16-bit range, through the queued scan-boundary rule. A newly accepted joint starts at angle zero before its first fixed step. Replaying the same accepted model and inputs produces the same coupling and physics traces. Helper-selected trace length and a serialized trace hash are not part of product compatibility. This contract does not yet define different-seed behavior, programmatic disturbance reordering, or overflow policy beyond the currently specified finite configuration limits.
6.9.4.1 Simulation configuration and conversion boundaries¶
An absent optional simulation file yields no configuration; a present
unreadable or malformed file returns a path-qualified configuration error.
With no sections, file-backed defaults are disabled, seed zero, and time scale
one. File-backed time scale must be at least one. SimulationController
defensively treats a programmatically supplied scale of zero as one without
silently enabling a disabled model.
Coupling sources are concrete %Q addresses and targets are concrete %I
addresses. Thresholds are finite. on_true and on_false require a threshold,
are parsed according to the target process-image width, and must fit that width
without truncation or wrapping. Coupling delays must fit the signed runtime
duration range. Without a threshold, numeric coupling conversion to
BYTE/WORD/DWORD is checked and overflow is an error; LWORD preserves the
accepted unsigned value. Bit conversion uses zero/nonzero truth. Fixed byte
strings must fit their configured byte count.
File-backed set disturbances default to kind set, require a concrete %I
target and a width-valid value, and require at_ms to fit the signed runtime
duration range. Fault kind is ASCII-case-insensitive and defaults its message
when omitted. Unknown kinds and missing set fields are rejected. Disturbances
are stably sorted by due time: declarations with equal due times retain file
order.
Physics configuration defaults to the in-tree Rapier backend, a 10 ms fixed
step, and 1000 encoder counts per radian. The step is within
1..=floor(i64::MAX / 1_000_000) milliseconds, the largest millisecond value
representable by the signed nanosecond runtime duration. Global and per-joint
counts-per-radian are finite and positive. Revolute joint velocity and bounds
are finite, lower does not exceed upper, IDs are unique, enable sources are
%QX bits, and feedback targets are %IW words. Disabled physics or enabled
physics with no joints does not infer simulation activation and creates no
physics controller.
Disabled controllers perform no disturbance, coupling, or physics I/O. Enabled pre-cycle processing applies equal-time disturbances and effects in stable insertion order. A source value that has not changed since its last post-cycle observation does not enqueue another effect. Backend address errors while reading a coupling remain I/O errors; a failed injected or delayed input write and a physics enable-read failure become visible simulation faults. These checks prove deterministic in-process model semantics, not physical plant fidelity.
6.9.5 Runtime control authorization¶
Runtime control uses the ordered role hierarchy viewer < operator < engineer <
admin; a higher role includes lower-role permissions. Authorization is checked
before dispatch, so a denied request must not change runtime, debug, I/O,
configuration, pairing, or connector state.
A role denial returns the stable wire error code insufficient_role together
with the required role in the human-readable error. Missing and invalid
credentials retain their separate authentication error codes. Clients must use
the stable code, not parse the prose, when distinguishing authentication from
authorization failure.
The control endpoint grammar is exact and whitespace-sensitive. tcp:// is
followed by a numeric IPv4 or bracketed IPv6 socket address with a required
port, and only a loopback IP is admitted. Hostnames, non-loopback addresses,
missing ports, trailing text, case variants, and leading/trailing whitespace
reject before binding. On Unix, unix:// is followed by a nonempty socket
path; no implicit default path is created. Endpoint accessors preserve the
parsed address/path, and a started server owns the same shared control state
supplied by its caller.
Control source identity preserves registration order, IDs, paths, and text.
Exact stored paths have first priority. For a relative request with a project
root, lookup next considers <root>/<request> and, for a single path
component, <root>/src/<request>. Existing candidates may match an existing
registered path through filesystem canonicalization. Only after those checks
may a relative suffix match be used, and it succeeds only when exactly one
registered path has that suffix. Ambiguous suffixes and unmatched absolute
paths return no file. Source-text lookup is exact by file ID and returns the
first registered row for a duplicate ID.
Web-to-control dispatch injects a request token only when the JSON payload has
no auth member; an explicit string, null, or malformed auth member is never
overwritten. Communication operations comm.apply, comm.test,
comm.browse_symbols, and ads.import_symbols.apply receive one
server-owned credential_channel parameter. Missing client identity, unix,
loopback, and numeric loopback socket addresses classify as
trusted_same_host; every other client identity classifies as
untrusted_remote_plain_tcp. The server-owned value replaces a caller-supplied
value, but a non-object parameter shape remains unchanged for the handler to
reject. Other operations receive no injected field.
Successful status responses whose result is an object include access
capabilities for the authenticated role. Viewer and Operator cannot write,
force, or release values; Engineer and Admin can. A denied role
includes a stable human-facing reason beginning with the capitalized role,
while Engineer and Admin use JSON null. Non-object or absent results are not
rewritten.
Authentication and transport defaults are:
- A configured control token identifies the Admin role. A valid pairing token identifies its stored role. Missing and invalid tokens fail before role authorization whenever control authentication is required.
- An untrusted network client without configured authentication receives only Viewer authority. A local Unix control socket with no configured token may use Admin authority because the socket is local and created with mode 0600.
- An unsupported request type performs no handler action. A request type that is not explicitly classified by the role policy uses an Admin fail-safe until it is reviewed; it must never inherit Viewer authority by default.
The explicit operation matrix is:
| Minimum role | Operations |
|---|---|
| Viewer | status, capabilities, health, tasks.stats, events.tail, events, faults, config.get, connectors.status, ADS/discovery/status/route-plan reads, comm.capabilities, comm.schema, comm.discover, comm.search.start without a confirmed fieldbus scan, comm.search.status, comm.search.cancel, comm.compare, fleet.topology, io.list, io.read, hmi.read (specification 39, §11), historian reads, debug state/stack/scope/variable/location reads, breakpoints.list, var.forced, program.active |
| Operator | pause, resume, restart, identify, pair.claim |
| Engineer | device.get (specification 34 TARGET-02), step operations, cycle.step, breakpoint mutation, eval, set, debug.set (section 6.13.4), variable and I/O write/force/release, clock.set, debug.evaluate, communication apply/test, a confirmed fieldbus scan (comm.search.start with confirm_fieldbus_scan = true), ADS server doctor actions |
| Admin | shutdown, bytecode.reload, program.install (specification 34 §11.2, refused unless the resource is Stopped), firmware.install (specification 34 TARGET-04, refused unless the resource is Stopped), device.set and device.reset (specification 34 TARGET-02, refused unless the resource is Stopped), the transfer operations transfer.begin, transfer.part, transfer.status, transfer.get and transfer.abort (specification 34, PORT-05), ADS route add/remove, pairing start/list/revoke, and security/exposure configuration listed below |
Parameter-sensitive operations retain these boundaries:
- ADS doctor and symbol import/browse are Viewer-only for cached/offline reads
and Engineer for live-device or write-enabled work. Presence of a non-null
live
targetrequires Engineer even if a snapshot is also present; later request validation may still reject that ambiguous combination.ads.import_symbols.applyis always write-enabled and therefore always requires Engineer, including when its source symbols came from a cached snapshot.ads.import_symbols.removewrites the project'sads.tomland generated ST and therefore always requires Engineer too. comm.search.startrequires Engineer when its parameters setconfirm_fieldbus_scanto the JSON valuetrue, and Viewer otherwise. A request whose value is not a Boolean is refused by request validation and runs no source.config.setrequires Admin if any request key iscontrol.auth_token,control.mode,control.debug_enabled,web.auth,mesh.auth_token,mesh.role,mesh.zenohd_version,mesh.plugin_versions,runtime_cloud.profile,runtime_cloud.wan.allow_write, orruntime_cloud.links.transports. Other currently supported configuration keys require Engineer.- Debug variable and I/O write, force, and release operations require Engineer for every address class. Viewer and Operator requests are denied before any queued write or force state changes. Enabling the debug surface itself requires Admin; an Engineer may use an already enabled surface but cannot enable it.
The registry is the single classification authority. Its operation names are exact, lowercase wire tokens; case variants, whitespace variants, and unknown names are unclassified and receive the Admin fail-safe. Every dispatchable operation appears exactly once, no non-dispatchable alias receives authority, and the debug-surface flag is true only for debug state, stack, scope, variables, locations, pause/resume/step, breakpoint, evaluation, variable write, variable force, and variable release operations. A classification change is a security-contract change and requires the complete role matrix to be reviewed, not merely one example request.
6.9.6 Control credentials, pairing, and TLS materials¶
Role text is trimmed and matched ASCII-case-insensitively against exactly
viewer, operator, engineer, and admin; serialization and display use
those lowercase spellings. Permission inclusion follows that exact total
order. Secret comparison returns true only for equal bytes and equal length,
including the empty string, and examines the complete expected byte length
even when the provided value is shorter.
Control authentication applies this precedence:
- A configured primary control token, when exactly matched, grants Admin.
- Otherwise a supplied valid pairing token grants its stored role.
- If credentials are required, an absent token returns
missing_auth_tokenand a supplied but unmatched token returnsinvalid_auth_token. - Without required credentials, a client explicitly identified as
unixor an in-process client with no transport identifier receives Admin. A network-style client identifier containing:receives Viewer. An opaque non-network identifier does not by itself reduce local authority.
Credential-state lock failure is authentication failure. It cannot be interpreted as “no token configured” and cannot grant local or network authority.
A pairing session owns at most one pending code. Starting again replaces the
previous pending code. A code is exactly six ASCII decimal digits, expires
300 seconds after creation, and remains valid at its exact expires_at
second. Claim input trims surrounding whitespace. A wrong code does not
consume the pending session; an expired or successful claim does, and so does a
claim with the right code that is then refused. A claim without a pending code,
with a wrong code or with an expired one is refused with invalid or expired
code. A successful claim returns one URL-safe, unpadded 32-byte random token,
stores it enabled, and assigns a unique stable ID even when multiple claims
occur in one clock second. Codes and tokens come from a cryptographically
secure random source; a runtime whose source gives none refuses pair.start
and the claim with the source's error rather than use predictable numbers.
The default pairing role is Operator. Viewer, Operator, and Engineer requests
retain their requested role; an Admin request is reduced to Engineer so the
pairing flow cannot mint an Admin credential. At most 256 enabled pairing
tokens may exist. Reaching that bound rejects the claim without creating a
token, with at most 256 pairing tokens may be enabled; revoke one first, and
a later claim is possible after a token is revoked. Tokens expire
30 days after creation and remain valid through their exact expires_at
second. Validation accepts only an enabled, unexpired, byte-exact token and
returns its stored role.
Listing preserves storage order, never exposes the token, and reports only a Unicode ellipsis plus the last at most four characters. Revoking an exact ID disables only that token and is idempotently false when the ID is absent. Revoke-all returns the number that changed from enabled to disabled. Disabled tokens remain visible until expiry but never validate and are not counted toward the enabled-token limit.
The pairing file is a JSON object with a tokens array. An absent file loads
an empty store. Unreadable or malformed content grants no token authority and
is never replaced: each later claim, and each revocation that would change a
token, is refused with the read error (read pairing file … or parse pairing
file …) until the file is repaired or removed and the runtime starts again.
Legacy rows with expires_at = 0 derive
created_at + 30 days with saturating arithmetic; already expired legacy rows
are pruned rather than temporarily revived. Expired rows are removed on load
and before every pairing, claim, validation, list, or revoke operation.
Successful token creation and revocation become externally successful only
after the resulting file is durably written; a write that fails is refused
with its error and leaves every token as it was. On Unix the credential file
mode is 0600; replacement must not expose a partial JSON credential set: the
runtime writes a new file beside it and renames it over the old one. A runtime
that holds no pairings answers the pair.* requests with pairing
unavailable.
A chip pairs by the same rules and the same requests, pair.start,
pair.claim, pair.list and pair.revoke, with the same replies
(specification 34 S5; the shared rules are one implementation that both
runtimes call). It keeps the same JSON object, without spaces, in a record of
two flash sectors, its chip file's pairing_a and pairing_b, written as its
device settings are (specification 34 TARGET-02), so that a power cut during a
write keeps the previous tokens. The ESP32-C6's sectors are 4,096 bytes, of
which the record's header takes 20 (specification 34 Appendix D.2): room for
24 tokens of 169 bytes each. A claim that the record cannot take first removes
disabled tokens, the oldest first, since they grant nothing; a claim that the
record cannot take even then is refused with the device has no room for
another pairing, and a revoked or expired token makes room. The times are the
chip's wall clock: a chip whose clock is not set refuses pair.start and
pair.claim with pairing needs the device's clock: set it with a time server
or clock.set, and takes no pairing token until its clock is set, so a token
can never outlive its 30 days; pair.list and pair.revoke still answer, and
no token expires before the clock is set. A chip
draws its codes and tokens from its hardware random number generator, which
is random only while the radio runs, so it pairs only while it serves a
network and refuses pair.start otherwise with pairing is for clients on
the network, and the device serves none.
TLS material loading is inert when TLS mode is disabled: paths may be absent and no file is read. Every enabled mode requires certificate and private-key paths. An absolute path is used unchanged; a relative path requires a project root and resolves against it. An optional CA path resolves by the same rule. Read failures identify certificate, key, or CA ownership and the resolved path. When CA is omitted, the server certificate bytes are the client trust root. The tiny-http projection contains owned copies of certificate and private-key PEM only.
A rustls server configuration requires at least one valid certificate and one valid private key, and rejects malformed, empty, or certificate/key-mismatched material. A rustls client configuration requires at least one valid CA certificate and rejects malformed or empty trust roots. Provider installation is process-wide and repeatable; failure remains a visible control error rather than silently selecting an insecure configuration.
6.9.7 Runtime web authentication and POST admission¶
The embedded web surface applies one shared authentication contract before a
route may read or mutate runtime state. In local mode, authority is derived
only from the request socket: an IPv4 or IPv6 loopback peer receives Admin and
every non-loopback or unavailable peer identity is unauthenticated. In token
mode, the first X-Trust-Token header name is matched
ASCII-case-insensitively, while its value is byte-exact and is not trimmed. An
exact primary web token grants Admin. Otherwise, an exact enabled pairing token
grants its stored role. Missing, unknown, expired, or disabled tokens are
unauthenticated. Failure to read primary credential state fails closed and
must not fall through to pairing authority.
Role authorization follows the hierarchy in section 6.9.5. An authenticated
role below the route requirement returns forbidden; absent authority returns
unauthorized. Both denials are JSON objects with ok: false and the stable
error text, use application/json, and return HTTP 403 and 401 respectively.
The accepted token value is forwarded to control dispatch. A server-owned
internal control token is used only when the web request supplied no token; it
must not replace an explicit token.
The I/O configuration routes follow the same contract in every web server mode.
GET /api/io/config requires Viewer and is an observation projection, not a
credential retrieval surface: every driver parameter whose key is secret-valued
(password, auth_token, token, secret, client_secret, credential,
credentials and private_key, matched ASCII-case-insensitively at any depth)
is replaced with the marker <redacted> before serialization. POST
/api/io/config requires Engineer; a secret-valued parameter that arrives as
<redacted> keeps the value stored for the driver at the same position with the
same name, so saving a configuration as it was read never erases or replaces a
credential. A marker with no stored value to keep is refused as an invalid
configuration, and nothing is written. A stored secret literally equal to the
marker is still an existing value and must survive a read/save round trip.
Restoration also binds a retained credential to its authentication destination.
For mqtt and mqtt-tcp, the requested and stored configurations must have the
same parsed broker host/port, TLS mode, CA/client-authentication material and
trimmed TLS file paths, ALPN, username, client ID and effective
allow_insecure_remote policy. The driver's parser owns defaults and validation.
Topic, mapping and timing edits may retain the password. A changed authentication
context requires explicit replacement credentials; a marker is refused before
writing any file. For other drivers, any retained marker requires the complete
submitted parameter object to equal the stored redacted projection. Comparing
two redacted projections is insufficient, because an explicitly replaced opaque
credential container can itself change the destination. Invalid context errors
are generic and never echo credentials. Configuration comparison does not freeze
external DNS or changes to certificate material at an unchanged path.
The same projection and restoration apply to /api/ide/io/config,
/api/runtime-cloud/io/config, /api/config-ui/io/config and generic IDE file
access to io.toml, in both runtime and standalone IDE modes. Text editing
preserves unrelated fields and comments outside opaque redacted secret containers. Revision tokens and IDE versions
are based on the original stored text, so even a secret-only concurrent change
causes a stale-write conflict. Invalid configuration responses must not echo
source snippets containing credentials. A proxy must project successful remote
I/O responses and must not forward arbitrary remote error text.
The generic IDE cannot rename io.toml to another filename or follow a final
symbolic link; these restrictions prevent aliases from bypassing the credential
projection. Directory moves retaining the io.toml filename remain supported.
Creating a new I/O file validates its configuration and refuses a marker without
a stored value. These rules do not remove the authenticated I/O read/save flow.
The X-Trust-Ide-Session header name is
also ASCII-case-insensitive and its value is preserved exactly.
Every state-changing API route applies the following POST admission rules before parsing or dispatch:
- Where JSON is required,
Content-Typeis the media typeapplication/json, matched ASCII-case-insensitively and optionally followed by media-type parameters. Prefix and suffix lookalikes such asapplication/jsonxandapplication/json-patch+jsonare rejected with HTTP 415 andcontract_violation. - A request without
Originis accepted only when its socket peer is loopback. A non-loopback or unavailable peer withoutOriginis rejected with HTTP 403 andpermission_denied. - A supplied Origin requires Host. The literal opaque origin
nullis denied. Otherwise, after ASCII case normalization and removal of at most one terminal slash, Origin must equal<scheme>://<host>, where the scheme ishttpsexactly when web TLS is enabled and Host is the trimmed request Host value. Host, scheme, port, path, user-info, and prefix/suffix mismatches are denied; origin comparison never uses prefix matching. - JSON body admission is inclusive at the configured byte limit. Reading at
most one byte beyond that limit distinguishes an oversized body from a
malformed JSON body without unbounded allocation. A body of exactly the
limit is parsed; a larger body returns HTTP 413. Empty or malformed JSON
returns HTTP 400. Limit arithmetic is overflow-safe for every
usizevalue.
POST-policy denials are JSON with Content-Type: application/json, ok:
false, a stable denial_code, and the documented error text. Admission proves
only request framing, origin, and body shape; route authentication,
authorization, and semantic validation remain separate mandatory checks.
Shared web helper grammar is deterministic:
- Header names are ASCII-case-insensitive; the first matching value is trimmed at its outer ASCII whitespace and otherwise preserved.
- A web listen socket projects to
httporhttpsaccording to TLS. Wildcard IPv4 projects tolocalhost; hostnames and IPv4 retain their host and port; IPv6 is emitted in bracketed URL authority form. - Query parsing uses the first exact key.
limitis an unsigned decimal integer. General values decode+as space and valid%HHoctets before lossy UTF-8 conversion. A malformed or incomplete percent escape is preserved literally rather than fabricating a NUL byte or silently deleting source characters. - A Runtime Cloud rollout action path is exactly
/api/runtime-cloud/rollouts/<nonempty-id>/<nonempty-action>with no extra path component. Outer whitespace inside either captured component is removed; path-prefix, suffix, and case variants do not match. - Probe JSON succeeds only for
ok: true. Success projectsresult.plc_name, falling back toresult.resourceand thenPLC, plusresult.statefalling back toonline. Invalid JSON, missing/falseok, or a non-string error projects{ok:false,error:"unreachable"}; a string error is preserved. - QR output is a complete SVG for the exact input bytes or a visible control error when the payload exceeds encoder capacity. Wall-clock helpers expose Unix-epoch milliseconds and nanoseconds, with nanoseconds never earlier than the contemporaneous millisecond value multiplied by one million.
6.10 Configuration and Resources¶
IEC configurations model one or more resources (IEC 61131-3 Ed.3, §6.8.1;
Table 62). The runtime scheduler supports several independently constructed
resources, each scheduled in its own OS thread. A build compiles exactly one
resource into one program image (specification 44 NX-03): it accepts zero or one
explicit RESOURCE and rejects a multi-resource source configuration instead of
flattening it. Multi-resource orchestration therefore uses separately built
runtimes; one source build does not yet project an IEC multi-resource
configuration into several runtime objects.
Cross-resource data exchange through shared globals (IEC 61131-3 Ed.3, §6.8.1; Table 62) is later work, with several resources per image (specification 44 §16): the bytecode engine's shared-global exchange between resources was deleted with it (specification 44 §15), and a runtime runs one resource.
Runtime configuration loading and validation contract¶
runtime.toml, io.toml, and enabled connector sidecars are truST product
contracts, not IEC decisions or deviations. Their user-facing field reference
is maintained under docs/public/reference/config/; the following rules are
normative for parsing and activation:
- Runtime and I/O TOML use closed schemas. Unknown fields, wrong shapes,
unsupported enum values, incomplete conditional groups, and invalid security
combinations fail as
InvalidConfigbefore a configuration is returned. Validation errors retain the owning file name. Defaults are applied only to omitted documented optional fields and never replace a malformed explicit endpoint, path, interface, producer path, symbol pattern, or version-map entry. When any of those values is present, it must be nonempty after trimming; list and map parsing must not silently discard blank entries. - Resource, task, retain, watchdog, ADS worker, OPC UA client polling, and
other millisecond values converted into the signed runtime
Durationmust be representable without narrowing. The runtime duration stores signed nanoseconds, so unless a field has a stronger documented minimum, its accepted millisecond range is1..=9223372036854(i64::MAX / 1_000_000); larger values reject instead of overflowing or panicking during conversion. OPC UA client polling retains its stronger minimum of 10 milliseconds. io.tomlaccepts exactly one nonempty driver form:io.driverwith table parameters, or a nonemptyio.driverslist whose names are nonempty and whose parameters are tables. Safe-state values are parsed against their addressed output width. BYTE, WORD, DWORD, and LWORD accept only their exact unsigned ranges; narrowing, wrapping, clamping, or substitution is forbidden.- TCP control requires a nonempty authentication token. Enabled remote web or mesh surfaces obey the configured TLS requirement. Enabled TLS requires the documented certificate/key group, provisioned TLS additionally requires a CA, signed deployment requires a keyring, and enabled connector/server sections must satisfy their documented credential, endpoint, allowlist, capacity, and explicit insecure-transport acknowledgement rules.
- Bundle loading requires a project directory,
runtime.toml, validated project or system I/O configuration, and the program imageprogram.tpi; a project that holds only the bytecode engine'sprogram.stbcis refused with the advice to rebuild it (section 6.8). ADS and OPC UA client sidecars are loaded only when enabled; relative sidecar paths resolve against the bundle root, enabled missing sidecars fail visibly, and the retained evidence hash covers the exact bytes that were parsed. - Loading is fail closed. No partially parsed runtime, I/O, connector sidecar, or default substitute is returned after any required file read or validation failure.
The configuration contract enums use trimmed ASCII-case-insensitive input and
closed vocabularies. Web authentication accepts local or token. TLS accepts
disabled, self-managed (with the compatibility spelling self_managed),
or provisioned. Mesh role accepts peer, client, or router. Runtime-cloud
profile accepts dev, plant, or wan; Plant and WAN require secure
transport while Dev does not. Preferred runtime-cloud transport accepts
realtime, zenoh, mesh, mqtt, modbus-tcp (with compatibility spelling
modbus_tcp), opcua, discovery, or web. OpenOT fence mode accepts
fenced or unfenced, and its source accepts heartbeat or exact st-fb.
Canonical output always uses the hyphenated lowercase spelling. Empty text,
near matches, extra words, unsupported punctuation, and NUL-bearing values
reject with the owning configuration key in the error.
Default optional runtime connectors are disabled. The ADS client sidecar is
ads.toml with a 20-millisecond worker tick; the OPC UA client sidecar is
opcua_client.toml with a 250-millisecond polling interval. Default OpenOT
telemetry is disabled, has an empty path, capacity 4096, fenced mode,
heartbeat source, no producer alias or producer list, and does not allow
unfenced proof. These defaults apply only when the corresponding documented
configuration is omitted; they cannot replace malformed explicit values.
Runtime web setup and I/O projection contract¶
The web setup surface uses the explicitly selected bundle root, otherwise the
current directory. A derived default resource name is the final path component
with every non-ASCII-alphanumeric character replaced by _; a missing
component uses trust-plc. Existing valid runtime.toml and project
io.toml values take precedence over platform defaults. Where setup chooses
the I/O driver itself, the platform default is gpio on a Raspberry Pi and on
the supported chips (specification 34, TARGET-01), and loopback on every other
computer (owner decision O26). Setup need remains true while runtime
configuration is absent or neither project nor system I/O configuration exists.
I/O mutation accepts either a nonempty ordered drivers list or the legacy
single driver, with the list taking precedence when present. Driver names are
trimmed and nonempty. Parameters are JSON objects converted recursively to
TOML tables; null becomes an empty string, and Boolean, representable integer,
float, string, array, and object shapes retain their value. A top-level
non-object parameter rejects rather than being wrapped or discarded. Every
driver is enabled in the resulting configuration.
The I/O response preserves driver order, parameters, safe-state order, source
identity, and system-I/O selection. Its legacy primary driver and params
are the first configured driver. Address projection uses %I, %Q, or %M
plus X, B, W, D, or L, retains bit indices, and emits %<area>* for a
wildcard. Boolean safe values use TRUE/FALSE; integer and bit-string widths
use unsigned or signed decimal without narrowing.
Saving project I/O validates the complete rendered TOML before replacement.
Selecting system I/O removes only the project io.toml; selecting project I/O
does not change system configuration. Setup validates and writes
runtime.toml, writes validated project io.toml unless system I/O is
selected, and invokes system setup only when explicitly requested.
Source listing inspects the direct src/ directory, includes regular .st
files case-insensitively, and sorts names. Source reads canonicalize both the
src/ root and requested path and reject traversal or symbolic escape.
6.11 Program Image (Overview)¶
The runtime runs the program image that the build writes (specification 44 §5): its code, its constants, its starting memory, the sizes of its memory areas, its tasks, its I/O bindings, its symbol and type tables, its statement table, its retain map and its signature. The runtime admits an image before it configures the resource and refuses one of another format version, CPU, kernel interface or program library with its reason (specification 44 §10.2). The STBC bytecode format of specification 12 is superseded.
6.12 Browser UI, Discovery, and Mesh (Operational UX)¶
Operational UX is browser‑first (no app). A built‑in web service exposes an operational UI and discovery metadata. This is implementer‑specific and outside IEC 61131‑3 scope.
Configuration (in runtime.toml):
[runtime.web]
enabled = true
listen = "0.0.0.0:8080"
auth = "local" # local|token
[runtime.discovery]
enabled = true
service_name = "truST"
advertise = true
interfaces = ["eth0", "wlan0"]
[runtime.mesh]
enabled = false
listen = "0.0.0.0:5200"
auth_token = "change-me"
publish = ["Status.RunState", "Metrics.CycleMs", "TempA"]
[runtime.mesh.subscribe]
"Plant-1:TempA" = "RemoteTemp"
Rules:
- Local‑only by default. Remote access must be explicitly enabled. A new project's
runtime.toml listens on this computer only (127.0.0.1:8080, discovery off). The runtime.toml
that a device's setup writes (setup, the web setup and the wizard) listens on every interface and
advertises the runtime, because setting up a device is the explicit choice of remote access.
- Request isolation is bounded. The runtime admits read-only requests on a
four-worker lane with at most 32 queued requests and body-bearing or mutating
requests on a separate four-worker lane with at most four admitted requests.
An incomplete request body must not prevent an unrelated admitted request
from being served.
- Saturation fails visibly. A request that cannot be admitted without
exceeding its lane's fixed bound receives HTTP 503 Service Unavailable, a
Retry-After: 1 header, and the stable denial code server_busy; the server
does not wait behind an incomplete body or create an unbounded worker thread.
These limits are internal runtime safety constants in this contract version,
not runtime.toml settings.
- Discovery uses mDNS/Bonjour on the local LAN only.
- The advertised and browsed service type is
_trust._plc._tcp.local.. Service construction enables automatic interface
addresses. The TXT projection preserves stable runtime id and name,
web port and TLS flag, optional mesh port, optional control endpoint, and a
trimmed nonempty host group; a missing or malformed optional value is absent
rather than fabricated.
- The TXT record also carries version (the truST version), platform (the
operating system and architecture, for example linux-aarch64, or the chip,
for example stm32u5a9), and auth (token when the control endpoint needs
a token, otherwise none). The id is the runtime's stable runtime ID: it is
generated once with a random part on first start and kept in the project's
.trust-runtime/runtime_id, read back on every start, so it stays the same
across restarts and differs between two runtimes on one host, and it is never
built from a process ID. A file that cannot be read or holds an invalid ID is
an error, never silently replaced with a fresh ID. A project folder that
cannot hold the file, because the file or its folder cannot be created or
written there (a read-only or foreign project folder), has the ID kept for the
user in runtimes/<h>.id of truST's folder in the user's state folder, where
<h> is the project-path hash of section 6.8.0a: $XDG_STATE_HOME/trust (an
absolute value only), else $HOME/.local/state/trust, on Linux and the other
Unix systems, $HOME/Library/Application Support/trust on macOS and
%LOCALAPPDATA%\trust on Windows. While the project holds no file, the runtime
reads the ID kept for the user on every start, under the same rule, so the ID
stays the same, and the log names where it is kept
(runtime_id_kept_for_user). When neither place can hold it, the runtime
still starts, with an ID for its process only, and logs the warning
runtime_id_not_kept with the reason (specification 34, ARCH-07: a tooling
file never stops a PLC program). The host name is the operating system's host
name. Web port and TLS flag are announced only while
the web server is enabled.
- control names only a control endpoint that another computer can use: a
tcp:// or tls:// endpoint on an address that is neither loopback nor
unspecified. A Unix socket path or a loopback endpoint is of no use to a
peer, and a socket path would reveal user and project names on the network,
so neither is advertised (section 6.8.0a).
- interfaces limits announcing and browsing to the named network interfaces;
an empty list uses every non-loopback interface.
- trust-runtime ide serve and setup mode announce and browse in the same way
as a running runtime. trust-runtime ide serve without a project browses
only, because it has no runtime to announce.
- /api/discovery and /api/probe require the same web authentication as the
other routes. /api/probe accepts exactly POST /api/probe, whose JSON body
carries url, username and password; credentials never travel in the URL,
and every other request on that path, a GET or any request that carries a
query string, is refused with 405.
- A resolved service replaces the entry with the same runtime ID and receives a
fresh observation timestamp. A removal event matches the exact entry ID or
runtime name, or an mDNS instance name whose final -<runtime-name> suffix
identifies that runtime. It removes only the matching entry.
- Remote access supports manual add and invite/QR pairing only.
- Data sharing is explicit (publish/subscribe mapping only).
Realtime T0 transport and cycle contract¶
Realtime T0 is a truST host-runtime transport outside IEC 61131-3. It is separate from the generic network mesh described below.
Route and QoS admission
QosTier::T0HardRtadmits onlyRealtimeRoute::T0HardRt.QosTier::T1Fast,QosTier::T2Ops, andQosTier::T3Diagadmit onlyRealtimeRoute::MeshIp.- A T0 publisher or subscriber bind requested through
MeshIpreturnsT0ErrorCode::ContractViolation. No mesh/IP connection is attempted.fallback_denied_totaland the selected channel'sfallback_denied_counteach increase by one for the denied bind. The diagnostic contains the stable fragmentnon-HardRT. - The canonical T0-to-communications error mapping is:
| T0 error | Communications error |
|---|---|
NotConfigured |
NotConfigured |
ContractViolation |
RtContractViolation |
SchemaMismatch |
SchemaMismatch |
StaleData |
StaleData |
TransportFailure |
TransportFailure |
The stable remediation for RtContractViolation is:
Use pre-bound T0 handles and fixed-layout payloads; generic IP mesh is non-HardRT.
Registration, shared-memory handshake, and binding
- Channel registration validates a nonempty channel identity, schema identity, and schema hash plus a positive fixed slot size and positive bounded-spin limits before publishing readiness.
- Failure to create or open the configured shared-memory root returns
T0ErrorCode::TransportFailure; its diagnostic identifies T0 shared-memory directory provisioning with the stable fragmentfailed to create T0 SHM directory. - A transport made without a configured root (
T0Transport::new) keeps its channels in a root of its own under the system's temporary directory. The root's name holds the process's ID, the time at which the process first named such a root, and a count, so that no other transport opens it, not even a later process that the operating system gives the ID of one that ended. The transport removes that root, with every channel in it, when it is dropped; a configured root belongs to its caller and stays. - When page pinning is configured as required, an unavailable pinning provider
or failed pinning operation returns
T0ErrorCode::TransportFailure; the diagnostic contains the stable fragmentrequired page pinning failed. - Opening an existing channel mapping requires its channel identity, schema
identity, schema version, schema hash, slot size, stale threshold,
bounded-spin limits, and ownership to match the requested contract. A
mismatch returns
T0ErrorCode::TransportFailurewith the stable fragmentschema_hash contract mismatchbefore the runtime can use that mapping. - A same-length mutation of the reviewed shared-memory header is rejected on
the next registration as
T0ErrorCode::TransportFailure. The bounded deterministic corruption campaign does not authorize parsing arbitrary damaged metadata or changing the mapping length. - Publisher and subscriber binding requires a previously registered channel,
T0HardRt,fixed_layout = true, a positive payload size no greater than the registered slot, an exact schema-hash match, and initialized pinned channel state. An absent channel returnsNotConfigured; a schema mismatch returnsSchemaMismatch; a variable or invalid layout returnsContractViolation; and unready or unpinned state returnsTransportFailure. - The resulting publisher and subscriber handles retain the bound channel, route, payload size, and schema identity. Publish and read operations do not perform route discovery, key parsing, generic mesh calls, or substitute an unbound transport. The source-bound T0 hot-path gate keeps mesh/discovery and key-parsing APIs out of the reviewed shared-memory modules while retaining the explicit diagnostic that generic IP mesh is non-HardRT.
Latest payload, overrun, stale, and bounded-spin results
- A successful publish commits one payload whose length equals the bound
fixed-layout size. A later publish before the preceding value is read
replaces the readable payload with the latest value and increments the
channel's cumulative
overrun_count. - A successful fresh read copies that latest committed payload, returns its
monotonically increasing sequence and byte count, reports
dropped_updates = newest_write_sequence - previous_read_sequence - 1, and exposes the cumulativeoverrun_count. The read advances the channel's read sequence and resets its consecutive stale-miss count. - A read with no new committed payload returns
NoUpdatewhile the consecutive miss count remains belowmax(stale_after_reads, 1). Reaching that threshold returnsT0ErrorCode::StaleDataand incrementsstale_count. - A reader observing an unstable writer retries only within both configured
bounds:
max_spin_retriesandmax_spin_time_us. Exhausting either bound returnsStaleData, incrementsspin_exhausted_count, and incrementsstale_count. - Publish and read both require initialized pinned channel state. Either
operation returns
TransportFailureinstead of accessing an unready or unpinned mapping.
Cycle exchange and cloud-work isolation
begin_cycle(cycle)records the cycle identity, clears the current cycle's pre/post observations, and restoresmax_cloud_ops_per_cycleas that cycle's cloud-work budget. It does not clear the cumulative denied-work count.- Within a cycle,
PreTaskmay be marked at most once.PostTaskmay be marked at most once and only afterPreTask.PostTaskbeforePreTask, or a duplicate exchange point in the same cycle, returnsT0ErrorCode::ContractViolation. - A cloud-work request receives
min(requested_ops, cloud_budget_remaining). The remainder is denied, subtracted from no T0 budget, and added todenied_cloud_ops_total; granted work reduces only the current cycle's cloud budget. - Consequently, a budget of three grants two operations and then one from requests of two and three, recording two denied operations. A budget of one across ten separately begun cycles grants ten of ten requests for fifty and records 490 denied operations.
- Functional T0 publish/read ordering, payload sequences, and stale/overrun counters remain independent of excess cloud-work admission. This is an accounting and ordering contract, not a measured execution-time guarantee.
Cross-process visibility and proof ceiling
- Two processes using the same shared-memory root and an exact matching channel contract may bind the permitted publisher/subscriber roles. After a successful committed publish, the subscriber can observe the same payload and a fresh read result through the shared mapping.
- Focused software checks of route rejection, shared-memory exchange, functional sequences, and counters do not prove worst-case latency, cycle deadlines, scheduler jitter, PREEMPT_RT configuration, production memory locking, physical topology, or hard-real-time certification. Those claims require separately named platform and timing evidence.
Linux realtime host profile contract¶
The Linux realtime profile parser accepts fifo, rr, and other as the
documented scheduler identities. An enabled profile rejects scheduler other
and duplicate CPU-affinity entries before host activation. Kernel readiness
classification distinguishes an explicit PREEMPT_RT signal from an explicit
non-RT signal and retains an unknown result when neither signal is present.
The reviewed /proc parsers preserve the user-space realtime scheduler and
priority and read the requested memory-lock field without inventing a value.
When observed FIFO policy and priority match the request, the reviewed observation contains no errors. A strict profile hook returns an error and retains the collected error detail when affinity or another reviewed readiness check fails; it cannot report a failed profile as active. These parser and failure-path checks do not prove that the validation host is PREEMPT_RT, that page locking succeeded in production, or that any deadline or jitter bound was met.
Mesh envelope, mapping, and numeric codec contract¶
- A published mesh envelope carries the runtime source identity, an unsigned sequence, a publication timestamp, and the encoded PLC value. A successful encode/decode round trip preserves the source, sequence, and supported value.
- A subscription source uses
peer:key. Parsing splits at the first colon, trims both parts, and accepts the mapping only when both the peer and remote key remain nonempty. - Inbound mesh values are decoded against the current local target type. Integer
values outside that IEC type's range are rejected rather than wrapped, and a
JSON number targeting
REAL/LREALis accepted only when the resulting runtime value is finite. - A rejected mesh value queues no local update and leaves the current PLC value unchanged. The runtime does not clamp, wrap, normalize, or substitute a default value.
- The supported scalar codec is bounded and total over arbitrary input bytes: malformed bytes may be rejected, but decoding must not panic. This crash-only malformed-input guarantee does not claim a particular diagnostic or that every malformed byte sequence is rejected.
Mesh control-plane readiness, liveliness, QoS, and snapshot contract¶
- Mesh QoS classification uses slash-delimited key-space segments.
activetakes precedence overcfg, which takes precedence overdiag; a key with none of those segments uses the fast data profile. - The liveliness registry keeps a unique current peer set and a bounded insertion-ordered join/leave history. A leave removes only that peer and records the transition.
- Cloud readiness requires an established session, liveliness token/subscriber, and successfully declared identity and catalog queryables. A bounded wait whose deadline expires before all four conditions hold returns an error rather than readiness.
- Startup readiness flags for identity and catalog mean that the corresponding queryables were declared successfully. Those flags alone do not prove a query/reply payload exchange.
-
A resource snapshot response that times out or disconnects is an error. It must not be returned as a successful empty snapshot.
-
TOML remains the source of truth; offline edits are supported.
The local terminal dashboard is a projection of the same control contract. It parses resource status, tasks, I/O drivers, events, simulation state, and settings without inventing missing health. Settings include cycle interval and simulation fields. Command routing preserves the beginner guard and pause semantics; prompt navigation cannot mutate a read-only view. The rendered dashboard keeps its reviewed status, task, I/O, and event regions in a stable layout so an operator can compare snapshots without hidden field movement.
Runtime Cloud state, dispatch, and rollout contract¶
Runtime Cloud state projects the connected runtime context, discovery transport metadata, topology nodes and links, and their current lifecycle and health without inventing connectivity. A fresh mesh disconnect first becomes stale and only later partitioned according to the documented staleness threshold. Plant and WAN state access requires the secure profile transport preconditions. Removed device-list mutation routes return not found rather than changing topology through a legacy alias. Corrupt persisted configuration state becomes an explicit error with retained parse evidence and is never replaced by an in-sync default.
Dispatch evaluates the selected target before any operation. An unreachable target or exhausted bounded query budget returns a visible denial and must not fall back to applying the request on the local runtime. A local target remains operational when an unrelated peer is partitioned. A successful remote status, configuration, or I/O proxy request returns the remote payload. Configuration dispatch preserves one request correlation through the target control audit, and both successful and failed target operations retain their matching audit identity and semantic result.
A rollout advances through its ordered target states and completes only after the applied configuration is verified. An apply or reconciliation failure makes both the rollout and affected target fail with retained error evidence. Abort transitions an active rollout and its unfinished targets to aborted. Pause, resume, or abort against a terminal rollout returns conflict and cannot rewrite the terminal result.
Runtime Cloud action preflight and profile contract¶
Runtime Cloud federation is a truST product contract, not an IEC decision or
deviation. The /api/runtime-cloud/actions/preflight and
/api/runtime-cloud/actions/dispatch surfaces use the current 1.x contract;
minor-version changes within that major are additive, while another major or a
malformed version is a contract violation.
Every action request has a nonempty request_id, connected_via, actor, and
action_type, plus at least one nonempty target runtime. A target runtime may
occur only once in a request. connected_via must identify the local runtime.
Malformed global request state is a denied preflight even when no per-target
decision can be emitted; an empty decision list must never turn a contract
violation into allowed: true. Rejecting an empty, blank, or duplicate target
list prevents both a false successful no-op and repeated side effects against
one runtime.
Preflight is side-effect free and evaluates the complete request before
dispatch. status_read, cfg_apply, and cmd_invoke are the supported action
families; unknown actions are contract violations. Viewer may read status,
Operator may invoke ordinary commands, Engineer may apply ordinary
configuration, and the security/exposure keys listed in the runtime control
authorization matrix require Admin. Stale and unreachable nonlocal targets are
denied with stable per-target reasons. A dispatch whose dry_run flag is true
returns the preflight result without performing the mapped control operation.
Profile policy is applied after the common contract, role, freshness, and reachability checks:
devdoes not useruntime.cloud.wan.allow_write; cross-site writes remain governed by the ordinary role and target checks.plantrequires token-authenticated TLS and secure transport metadata for a nonlocal target, but it does not use the WAN write allowlist.wanhas the same secure transport preconditions and additionally denies every nonlocalcfg_applyorcmd_invokeunless an explicit action-and-targetruntime.cloud.wan.allow_writerule matches.*, a prefix such assite-b/*, a suffix such as*/runtime-b, and exact target IDs have their documented meanings; other embedded-wildcard shapes do not match.
The runtime communications conformance gate must execute every named native
case in its suite. A stale filter that selects zero tests is a gate failure and
must not be reported as conformance success. Case execution evidence is scoped
to the current invocation; a passing result retained in a reused output
directory cannot satisfy a later run. Its development-profile gateway case
verifies the permissive dev policy above; default-deny write coverage belongs
to the wan profile and must not be attributed to dev.
Preflight and dispatch responses retain the caller's request_id and
connected_via. Each dispatched target produces one result for that target;
successful target results carry the target audit correlation supplied by the
control operation. A denied or failed target must remain visibly denied or
failed and must not be summarized as a successful aggregate action.
A live update to runtime.cloud.wan.allow_write uses the ordinary validated
configuration-write path. The accepted exact action and target pattern is
visible in subsequent configuration reads, and the control audit retains the
configuration request correlation. An invalid policy update is rejected before
replacing the active allowlist.
The Runtime Cloud control and I/O proxy planners are action-request builders,
so they must either produce an action that already satisfies this common
contract or reject the proxy request before preflight and before any local or
remote side effect. Both planners validate API compatibility and reject a blank
actor, target runtime, or connected_via. A control proxy request may omit its
request ID, in which case the planner generates one; an explicitly supplied
blank request ID is a contract violation. The chosen control proxy request ID
is identical in the action request and forwarded control payload so audit and
response correlation cannot diverge.
Runtime Cloud desired-configuration reconciliation contract¶
Runtime Cloud desired configuration is also a truST product contract outside IEC 61131-3. A desired write has a nonempty actor and an object-valued merge patch. Optional expected revision and ETag values are optimistic-concurrency preconditions; a mismatch rejects the write as a revision conflict. An accepted write advances the desired revision, records a new ETag and writer, and becomes pending until the runtime control operation confirms it.
Reconciliation applies an immutable snapshot containing the desired value,
revision, and ETag. The control operation runs without holding the configuration
state lock. If another desired write is accepted while that operation is in
flight, the older completion may acknowledge only the snapshot it actually
applied: it must not copy the newer desired value into reported, attach the
older revision or ETag to newer content, or mark the agent in sync. The newer
desired revision remains pending and the next reconciliation attempt applies
it. A failed apply preserves the last confirmed reported value and revision and
surfaces a blocked or error state with the stable reason available from the
control failure. If that failure belongs to a superseded snapshot, its
diagnostic remains visible but the newer desired revision stays pending and
eligible for the next reconciliation attempt.
Runtime Cloud contract schema and canonical keyspace¶
Runtime Cloud API versions use the decimal <major>.<minor> form. Equal
versions are exact, versions with the same major are additive-compatible, and
different majors are breaking. Mesh schema evolution is forward-additive only:
field names are nonempty and unique, every field has a nonzero representable
offset + size range, and field ranges do not overlap. Every previous field
must remain present with exactly the same offset, size, and type. New fields may
start only at or after the end of the complete previous layout; inserting one
into an existing field or padding range is not an additive append.
Canonical Runtime Cloud keys are assembled from already validated nonempty
site, runtime, group, and identity segments. The root is
truST/<site>/<runtime>. Runtime-local zones are _meta, io, cmd, cfg,
diag, and svc; _meta, diag, and svc are reserved. The site-level
active slot may be published only by the current Active authority. Desired,
reported, status, and metadata configuration documents use the authoritative
site-level and runtime-alias paths defined by the keyspace helpers. Default UI
and operations staleness is max(2 * expected_period, 2 seconds). A retained
last value may support UI continuity but never a control path.
Runtime Cloud topology measurement and same-host contract¶
Runtime Cloud topology is a truST product projection, not an IEC decision or
deviation. Discovery heartbeat and reachability observations may establish a
node's lifecycle and a communication edge's healthy, stale, degraded, or failed
state. They are not latency or packet-loss measurements. Unless the projection
receives an actual measurement for a metric, latency_ms_p95 and loss_pct
remain absent; it must never manufacture a nominal constant for a healthy edge.
The T0_HardRt topology overlay is same-host only. Equal nonempty discovery
host_group values are positive same-host evidence; different explicit groups
are negative evidence. Without complete host-group evidence, an exact shared
advertised non-loopback IP address is positive evidence. A loopback address is
host-local and therefore proves only that the advertising runtime is colocated
with the dashboard's known local runtime. It must not classify two arbitrary
peer runtimes as colocated, and the result must not depend on source/target or
runtime-ID ordering.
A realtime preference creates a T0_HardRt overlay only while that same-host
evidence holds. The ordinary mesh operations edge may remain visible alongside
the overlay, but it is not a deterministic fallback for T0 and must not be
reported as one.
Runtime Cloud HA lease, split-brain, and replay contract¶
Runtime Cloud dual-host HA is a truST product safety contract outside IEC
61131-3. An action that addresses the active namespace, and every
cmd_invoke, requires a nonempty HA group, an external consistent lease
authority, an available lease, one ACTIVE target whose runtime ID is the lease
owner, a valid nonempty fencing token, and no ambiguous leadership. Loss of any
authority or fence condition denies the protected action before side effects
and requires the affected runtime to remain or become demoted_safe.
The supplied payload.ha.targets map is the asserted complete authority view
for the HA group, not merely a description of the action's target subset. If
more than one entry simultaneously satisfies every ACTIVE lease-owner and
fencing condition, the group is split-brain. Every protected action is denied
with lease_unavailable, including an action whose target_runtimes names
only one of those candidates. Selecting one candidate in the action must never
hide another valid ACTIVE owner from the split-brain check.
Dual-host commands use a positive command_seq per group and runtime. A
completed request ID with the same sequence returns its stored result without
performing the control operation again; reusing that request ID with a
different sequence or submitting a stale sequence is a conflict. While the
first dispatch for a request ID is still owned by the current live coordinator,
another begin attempt is also a conflict and cannot start a second control
operation. A serialized pending request carries no live-process ownership; on
recovery, the same request ID and sequence may be resumed once for
reconciliation and then becomes subject to the ordinary completed-result
deduplication rule.
Operational UX and pairing flow are documented internally.
6.13 Debugging and Diagnostics¶
The runtime emits structured events for debugging and testing: - Cycle start/end (with timestamp) - Task start/end (with task name, priority) - Breakpoint hit / step events (statement boundaries) - Fault and overrun notifications
These events are consumed by the debugger (trust-debug) and test harnesses to validate behavior deterministically.
6.13.1 Debug value freshness (owner, 18 October 2026)¶
eval, debug.stack and debug.variables answer on every runtime with the values as of the last
scan that ended, from one published debug state, and no runtime keeps a copy of PLC storage per
statement. This is a truST runtime contract; IEC 61131-3 defines no debugger and no variable
snapshot.
- The published state is the PLC storage as it stood at the end of the last scan that ended, with no call stack: a completed scan's, or a faulted scan's under the bullet below. It is one consistent state, never half a scan. A read never waits for, pauses, slows or repeats a scan, and a scan in progress is not visible in an answer: a variable it has already written, or a frame it has entered or left, does not appear until that scan ends and becomes the last scan that ended. Answers therefore come from the scan end, not from the latest statement that ran.
- A runtime that serves requests between scans (a chip's runner) answers from its storage, which is that state. trust-runtime answers from one copy it publishes at each scan end: the running scan writes the live storage the scan itself uses, and the published copy changes only at the boundary between two scans. That is one copy per scan, and it replaces the per-statement copy the runtime kept before.
- A faulted scan publishes the state it left (owner decision O2, 18 October 2026). A scan faults when its execution returns an error, including a deadline or output-commit failure inside the scan, or when its thread panics. Its state replaces the last completed scan's. On trust-runtime it is one copy of the storage as the fault left it, made before the fault's safe-state action, together with the fault's position. A failing statement stores nothing, and the stores the scan completed remain (section 6.6). The call stack at the fault is part of that state: every frame the fault unwound, with its local variables as the fault left them, as CODESYS and TwinCAT keep the call stack and its values in their exception dumps (owner, 18 October 2026, O2). The position is the innermost statement whose code span contains the failing instruction, as the installed module's LINE_TABLE names it (specification 12 section 6.13): the statement's file label, and its 1-based line and byte column. A fault that raised no instruction, an instruction that no statement contains, and a module without a LINE_TABLE name no position. On a chip the reads come from the live storage, which already holds the values the fault left, so no copy is made. A fault after a scan has ended, such as a watchdog overrun or a failed publication of shared variables, does not replace that scan's state. The faulted state stays published until the next scan ends, which replaces it, or a restart or an online change clears it. A reset of the fault latch (section 6.6) leaves it published.
- While a debugger holds the scan (a breakpoint, a debug-mode
pauseor a completed step, section 6.9), the published state is the halted state: the storage exactly as the statements that already ran in the suspended scan left it, and the call stack at the halt. Every stopped answer is unchanged, including the boundary writes a waiting scan already shows (section 6.9). When the hold ends, by a continue, a step or a stop, the published state is again the state of the last scan that ended, and the scan that resumes stays invisible until it ends. A halted state names no fault position. - Before the first scan ends there is no published state. Each read answers its existing error
(
no snapshot available) rather than an invented empty projection. - A read changes nothing: it applies no store, releases no force, and changes no value, breakpoint, hold or step, and it is subject to the requester's existing role (section 6.9.5).
6.13.2 Portable debug expressions (owner, 18 October 2026)¶
Every runtime evaluates debugger expressions in one portable compiled form. The tool compiles and
the runtime evaluates: a runtime parses no ST text for a watch, a breakpoint condition, a logpoint
fragment or debug.evaluate, and needs neither the parser, the HIR nor the authoring part of the
type registry (specification 34 ARCH-02 and the HOST-09 row "Debug expression parsing").
The same compiled form serves all four surfaces, so they cannot drift apart, and trust-runtime and a
chip evaluate a given expression with the same code. The expression semantics are those of IEC
61131-3 Ed.3 §7.3.2; the compiled form reproduces them, it does not define others.
A control request that carries an expression carries these two fields instead of ST text:
expr_version— the version of the compiled form the bytes are encoded in, an integer. The tool chooses it when it compiles, so a tool may address an older runtime with an older form.expr— the compiled form itself, base64 of the byte string (RFC 4648 §4 alphabet, padded).
The instruction encoding of that form, its operands, its type rules and its fixed size and work
limits are defined by the slice that builds it (S3c, portable debug expression compilation); this
section fixes only the protocol boundary that every runtime implements. The original text may travel
beside the compiled form for display only, and no runtime reads it. The boundary applies from the
release that implements it: a runtime that does not implement a version rejects it with
expr_version_unsupported and decodes nothing.
Version 1 (S3c, 24 October 2026). expr_version 1 is one expression in prefix order: each node
is a tag byte followed by its operands, every integer is little-endian, and every text is a 2-byte
length and that many bytes of UTF-8.
| Tag | Node | Operands |
|---|---|---|
| 1 | Literal | a value: its type code (1 byte) and its bytes, below |
| 2 | Name | a text, the name as written |
| 3 | Unary | the operator (1 byte: 1 -, 2 +, 3 NOT), then the operand |
| 4 | Binary | the operator (1 byte: 1 +, 2 -, 3 *, 4 /, 5 MOD, 6 **, 7 AND, 8 OR, 9 AND_THEN, 10 OR_ELSE, 11 XOR, 12 =, 13 <>, 14 <, 15 <=, 16 >, 17 >=), then the left and the right operand |
| 5 | Index | the number of indices (1 byte, 1 to 8), then the array and each index |
| 6 | Field | a text, the member's name, then the structure or the instance |
A literal's type codes are 1 BOOL (1 byte, 0 or 1), 2 SINT, 3 INT, 4 DINT, 5 LINT,
6 USINT, 7 UINT, 8 UDINT, 9 ULINT, 10 BYTE, 11 WORD, 12 DWORD and 13 LWORD (each in its
width), 14 REAL (4) and 15 LREAL (8, IEEE 754), 16 TIME and 17 LTIME (8, signed
nanoseconds), 18 STRING (a text), 19 WSTRING (a 2-byte count and that many UTF-16 units),
20 CHAR (1), 21 WCHAR (2) and 22 an enumeration's value by name, Mode#Run (its type's name
and its value's name, each a text). The runtime takes the value's number from the program it runs,
so that a project edited since its deployment cannot make an expression compare another value.
The runtime resolves a name where it evaluates, without regard to case: in a held scan's frame
first, among the frame's own variables (a function's or a method's inputs, locals and result) and
then the variables of the frame's instance; then among the globals; then as Instance.Variable, a
variable of a program instance. A member chain over a name, Ctr.Count, resolves its first name so
and each further name as a member of the instance before it; a member of a structure is taken from
the structure's value. The tool compiles with the types of the project's declarations, and the
attached debugger reads them from the project folder it is given. Evaluation computes as the runtime's statements compute
(specification 07, IEC 61131-3 §7.3.2): AND_THEN and OR_ELSE evaluate their right operand only
when the left does not decide, the tool inserts no conversion, and an operation that the runtime's
arithmetic refuses for its operands fails the evaluation. An encoding that is truncated or longer
than its expression, longer than 4,096 bytes or nested deeper than 32 nodes, a tag, an operator or a
type code outside these tables, a text that is not UTF-8, a name that resolves to no variable, an
enumeration or a value of it that the program does not declare and a result that is no value (an
instance) are expr_invalid; an index outside its bounds, a division by
zero, an overflow and more than 1,024 nodes evaluated are expr_evaluation_failed. The tool
compiles from the project's sources what this version can say, names, literals, the operators,
indexing and member access, and refuses an expression with any other part (a call, a reference,
THIS, SUPER, an initializer) before it sends a request. A compiled program evaluates version 1
on every device; the bytecode engine's text form was deleted with it (specification 44 §15).
A runtime answers with a value, in the stable debug representation and with the public type name,
exactly as debug.variables reports a variable of that type. Evaluation reads the published state
(section 6.13.1) and is side-effect free: it stores nothing, calls nothing with a side effect, and
cannot fault the PLC, block the scan, apply a force or change a breakpoint, a hold or a step. It
rejects with these stable codes:
| Code | Meaning |
|---|---|
expr_version_unsupported |
This runtime does not implement expr_version; it decodes nothing. |
expr_invalid |
The bytes do not decode to a complete expression this runtime accepts: truncation, an unknown instruction, an unknown name or reference, an instruction that would store or call something impure, or a result that is not a value. It is rejected before evaluation. |
expr_evaluation_failed |
The expression decoded and is supported but evaluation failed, for example a divide by zero, an invalid reference or an index outside its bounds. The detail text is non-normative and the PLC is unaffected. |
A watch, a breakpoint condition and a logpoint consume those answers as specification 13 already
defines: an invalid or failed expression makes a watch report failure, makes a breakpoint condition
false (no stop), and renders a logpoint fragment as <error: ...>. A client's debug.evaluate
receives the rejection itself — a failed response carrying the code and its detail, and no value.
eval keeps its own contract (section 6.8.1): it is a bounded exact name read, not an expression,
and it carries text.
Breakpoint conditions, hit conditions and logpoints (S3c, 24 October 2026). A
breakpoints.set may carry options, an array as long as lines whose entry i, null or an
object, belongs to the breakpoint of lines[i]:
condition:{"expr_version", "expr"}, a compiled expression; the breakpoint stops only where it givesTRUE.hit_condition:{"op", "count"},opone of=,>=and>, andcountan unsigned 64-bit number from 1, applied to the breakpoint's hit count after the hit is counted.log_message: an array of parts, each a text or a compiled expression{"expr_version", "expr"}; the breakpoint is a logpoint.
At a statement start the runtime keeps the order of specification 13 ("Breakpoint evaluation,
source resolution, and hook contract"): it counts the hit, applies the hit condition to the count,
then evaluates the condition in the frame that runs the statement, its names resolved as above; a
logpoint then records its message and never stops, and a breakpoint stops. A logpoint's message is
its parts in order, each expression replaced by its value as specification 13 formats a logpoint
value, or by <error: detail>, and cut to 1,024 bytes at a character boundary. debug.stops
answers the messages recorded since its last call beside the stops, as logs when there are
any, oldest first, each
{"text", "file_id", "path", "line", "column", "thread"} with the file's path as a stop names it
and the zero-based start of the logpoint's statement; their buffer is bounded as the stops' buffer is (section 6.13.3), and a message beyond the
bound replaces the oldest. A runtime refuses whole, and changes nothing, a breakpoints.set whose
options are not as long as lines, carry an op outside the three or a count of 0, or carry an
expression of a version it does not implement (below). A runtime that runs a compiled program
implements version 1 for all of them.
Before a runtime implements a version (owner, 18 October 2026). A runtime that implements no
expr_version yet, a chip until the tool compile exists, refuses every request that would evaluate
an expression and decodes nothing: debug.evaluate; eval, which such a runtime does not answer as a
name read; and a breakpoints.set whose parameters carry a condition or a log_message other than
null, at the top or in options, which it refuses whole, so that a breakpoint is never stored
without the condition the client asked for. The refusal is a failed response with the stable code expr_version_unsupported and the
message this runtime evaluates no debug expressions yet: the compiled expression form is not
implemented. A refusal changes nothing.
6.13.3 Debug requests without source text (owner, 18 October 2026)¶
A runtime that holds no ST source text, a chip, answers the debug operations of the role table
(section 6.9.5) from the program's LINE_TABLE (specification 12 §6.13) and the file labels of its
DEBUG_STRING_TABLE. The shared dispatcher routes debug.state, debug.stops, debug.stack,
debug.scopes, debug.variables, debug.breakpoint_locations, breakpoints.set, breakpoints.clear,
breakpoints.list, breakpoints.clear_all, breakpoints.clear_id, step_in, step_over,
step_out and debug.set (section 6.13.4) to one request module, with the role the table gives
each, and a read answers from the published debug state of section 6.13.1. Each reply is trust-runtime's reply for the same stop, field
for field: the frame and variable identifiers, the names, the stop reason labels, the error texts and
the error codes. Line and column numbers in a request and in a reply are zero-based, as trust-runtime
carries them; the table holds them one-based, and the conversion happens once, at the request. The
differences that follow from the missing text are these, with the handle bound below, and no others:
- File identity. A reply names a file by its label: the
pathof a source, and the last/component of the label as itsname. A file's identifier in a reply is the index of its entry in the table. A request names a file by a path: the file is the one whose label equals the path or is the longest tail of it after a/, a\counting as/, because a client holds a path and a label is relative to the project root. A path that ends with no label is refused withunknown source path. - Shared reply shapes.
breakpoints.listgives one entry shape on every runtime:file_id,line,column,end_lineandend_column, zero-based, in registration order, built by the one shared reply builder. A runtime that holds the text adds the statement's byte offsetsstartandend, which a runtime without the text cannot count. TheInstancesscope lists its instances in ascending instance identifier on every runtime, whatever order the storage's map iterates in. - The buffer of stops.
debug.stopsreturns the stops since its last call, oldest first, and empties the buffer. The buffer is bounded, and a stop beyond the bound replaces the oldest. The bound is the capacity of the event store (runtime.events.capacityof the project, or the device descriptor's default; section 6.8.1), because a stop is a smaller record than an event. A runtime that enables debugging takes the whole buffer at start, as it takes the event store, so that no scan allocates for it; a runtime that does not enable debugging takes none.debug.statereports the latest stop whatever the buffer holds. - Threads. Each task is one debugger thread. The threads are numbered 1, 2, ... in the order the resource declares its tasks, a task name that a later task repeats keeps the number of its first, and the programs that run outside every task are one more thread, numbered after the last task. The runtime sets the thread when a task starts, so a stop in a task reports that task's thread.
- Variable handles. The identifiers of a variable list restart at
1with everydebug.scopesand are valid until the next one (specification 13, "Runtime debug state, mutation, and projection invariants"), except the handles of an array's pages that a bounded table releases (below). - Handle bound. A runtime without the text holds at most 256 live variable handles, a handle
taken out for a listing counted. A listing that would take the table past that bound is refused
whole, with the stable code
variable_handles_fulland the messagethe variable handle table is full: request the scopes again, and the table stays as it was. The bound counts the handles of one page (see "Variable pages" below): when a page of an array finds the table short of the room that its handles need, the table first releases the handles of the oldest earlier pages of arrays, a whole page at a time and only as many pages as the room needs. It releases none when all the earlier pages together would not make the room, and the page is then refused as above. So the 300 elements of an array of structures list as three pages of 100 on a chip, each page taking its own handles. A released handle answersunknown variables reference, as a handle that was never issued does, and the client lists its page again to get new handles. A computer keeps its handles unbounded, as it always has, and releases none; the refusal is the same wherever a bound applies. - No line table. A program without a
LINE_TABLEhas no statement start to hold at or to name, so it cannot be debugged on a runtime that holds no text. Every debug operation above is refused with the stable codeno_line_tableand the messagethe program has no line table, so it cannot be debugged: rebuild the project, and a debug-modepauseis refused the same way before the debugger is asked to hold anything.resumeis unaffected.
Variable pages (every runtime). debug.variables lists the children of a handle in pages, as the
Debug Adapter Protocol's variables request does (specification 13, "Variables / Evaluate"), so that
the listing of a large array needs no more handles than one page, and an engineer can inspect every
element of a large array on a chip as on a computer. This is not one of the differences above: a
computer and a chip answer a given page with the same entries, because the same shared code cuts it.
The parameters are variables_reference and three optional ones, filter (indexed or named),
start and count. start and count are integers from 0 to 4,294,967,295; any other value of
the three is refused with an invalid params error, and the request changes nothing.
- Children. The children of an array are its elements, which are indexed. The children of anything else are named: the members of a structure, the variables of a scope or of an instance, the target of a reference, and the areas and rows of the I/O scope.
- The page.
filterkeeps the children of the kind it names and lists none of the other kind.startis the zero-based index of the first child to list among the children thatfilterkeeps,0when absent, andcountis the most children to list. A page lists the children fromstart, in their order, at mostcount, and fewer at the end of the list; astartat or past the last child lists none. A name keeps the index it has in the whole list: on the page that starts at 100, the first element is named[100]. - Without
count. Acountthat is absent or0lists a page of 100 elements of an array, the chunk in which VS Code pages. A request that names neitherstartnorcounttherefore answers an array's first page, and an old client still gets an answer that fits. The named children fromstartare all listed without acount, because VS Code requests the named children of a variable whole and would never see a member that a page cut off. - Counts. An entry whose value is an array reports
indexedVariables, the number of its elements, and an entry whose value is a structure reportsnamedVariables, the number of its members. Both are the size of the whole container, not of a page, so that a client can ask for each chunk. No other entry reports a count: a scalar entry has neither field. - Handles. Only the children that a page lists take handles, so a page of an array takes at most
counthandles, and at most 100 without acount, however large the array is.
6.13.4 Debugger writes (debug.set; the owner, 26 October 2026)¶
The owner, 26 October 2026: "did you run a program, simulate and force values etc? did you test all
workflows?". debug.set is a debugger's write of a variable, an Engineer operation of the debug
surface (section 6.9.5), which a chip and a computer answer alike through the shared request module
(specification 44 §13.2; specification 13, "setVariable and setExpression"). Its parameters are the
variable, either as a listing showed it, variables_reference and name (the handle of a
debug.variables or debug.scopes listing and the name it showed), or by a path (section 6.8.1,
"Runtime control I/O and variable mutation contract") with an optional frame_id, whose first name
is looked up among the variables of that frame of the held call stack before the program's names;
an optional value, the text of a literal of the variable's declared type; and force, true when
the client forces the variable. Any other parameter, or neither or both forms of the variable, is
refused.
- At a hold the value is written into the held scan at once, converted as a Live Values write is
converted, and the answer reports the value the variable holds after it, with
written: true. The reads after it, ofdebug.variablesanddebug.evaluate, show it, and the held statement and the statements after it see it. - Between scans, while the debugger holds no scan, nothing is written: the answer reports the
admitted value with
written: false, and the client queues it withsetor forces it withvar.forceby the answer'spath, for the next cycle start. - Without a value the variable is only read: the answer reports its value and its path, by
which a client releases its force with
var.unforce. - The answer is
{"path", "type", "value", "written"}: the variable's path, by which Live Values queue and force it,nullfor a variable of a frame, which exists only while the debugger holds the scan; its type's name; and its value in the stable debug representation. A row of the I/O scope is answered with{"address", "path": null, "written": false}: it is written, forced and released at its address withio.write,io.forceandio.unforce. - Refusals, each with a plain reason, change nothing: a handle the table does not hold, a name
the listing does not show, a child of an evaluated value, which is a copy and not a variable, a
path the program does not hold, a constant, a member or an element of one, an instance, which
holds no value of its own, a value its type refuses (the typed values above),
forcefor a variable of a frame, which no force can name, and a write the held scan refuses.
Hosted embedding mutation lifecycle (planned H migration)¶
Specification 34 §4.3.1 defines H's construction and installed-state boundary. Raw mutable storage and authoring type-registry access belong to a non-executable construction object. Installed controllers, including paused or stopped ones, use checked live value writes; debug parsing uses separate scratch types. Code, layout and executable type changes use an isolated validated candidate and the existing hosted online-change/warm-restart boundary. Ordinary supported writes and forces retain their existing timing and do not require STOP or reinitialization.
This is an explicit migration of Rust callers using live storage_mut() or
registry_mut(), not a reduction in PLC functions or hosted debugging features.
Until H implements and validates the migration, current hosted code still exposes
those raw mutable references. This specification amendment does not claim that
the new API or its regression tests already exist.
7. Build Configuration¶
7.1 Feature Flags¶
[features]
default = ["debug"]
debug = [] # enable debug instrumentation and runtime events
7.2 Conditional Compilation¶
Desktop builds use the standard library unconditionally. Embedded support will introduce additional cfg gates for alternative clock implementations.
8. Why Not Alternatives¶
| Alternative | Reason for rejection |
|---|---|
| Containers | Cannot run on microcontrollers. Adds complexity without benefit for this use case. |
| FreeRTOS POSIX simulator | Adds unnecessary layer on desktop. Not production-grade. |
| Embassy (async Rust) | Cooperative scheduling unsuitable for deterministic PLC timing. |
| WASM | Adds complexity. Real-time I/O interaction awkward. Could be future target. |
| Transpile to C | Loses runtime flexibility. Debugging harder. |
9. Future Considerations¶
WebAssembly target: The runtime core (executor, timers) could compile to WASM for browser-based simulation. Would require a WASM-friendly Clock implementation.
Remote I/O: Process image exchange via Modbus TCP is architecturally separate from the clock layer. Networking abstraction would be added alongside the Clock trait, not replacing it.
Retain variables: Persistent storage across power cycles requires platform-specific implementation (filesystem on desktop, flash on embedded). This is orthogonal to the Clock trait and would be added as a separate storage interface if needed.
10. Summary¶
The thin clock abstraction approach provides:
- IEC-aligned task scheduling (periodic and event tasks with defined priority rules)
- Minimal clock surface - easy to maintain and verify
- Clear separation - runtime logic vs clock primitives
- Deterministic behavior - explicit scheduling and I/O latching rules
- Testability - full runtime runs natively on development machine
The runtime is implemented in Rust, using the standard library for desktop targets initially. Embedded backends are planned with identical runtime logic and alternate clock implementations.