Skip to content

ADR 0086: PLC source is Structured Text, and the project file is a build artefact

Status: Accepted Date: 2026-09-01 Issue: #1892 Follows from: #941 (the field validation that needed an application on the controller), #1350 (the ruling that put CODESYS on the founder's laptop) Related: ADR 0076 (the same shape one layer in: an address names the driver that answers it), ADR 0074 (a value is published with what is known about whether to trust it)

Context

#941 needed a CODESYS application on the WAGO CC100 before its secured read/write/subscribe scope had any tags to exercise. Getting one there established that the whole build-and-download loop is drivable headlessly. It also produced a prototype that was an existence proof. It was put forward as one. This ADR records the decisions that prototype made silently.

Three facts about the toolchain sit underneath everything below.

The IDE is optional for the whole loop. CODESYS.exe --Profile=... --noUI --runscript=<file> drives an IronPython 2.7 ScriptEngine. It can create a project, add the device, author POUs and GVLs, create the task, configure symbols, import the I/O mapping, set the target address, build, log in, download, start, and write the boot application. Nothing in the routine loop needs a human at a mouse.

Structured Text is the only language this reaches. Graphical bodies (LD, FBD, CFC, SFC) have no textual representation to author or to diff. The CC100 device template's own default PLC_PRG is Ladder, which is why the shipped project had no readable implementation. A workflow built on text authoring is an ST-only workflow.

Three toolchain calls report success over failure, and a fourth turned up while this was being decided. CODESYS.exe exits 0 whether or not the script inside it raised. app.build() does not raise on a failed build, and files its errors in a message category that get_message_objects(None, ...) does not read. That is how a tree carrying fourteen errors was once called clean. import_io_mappings_from_csv() reports nothing when a row binds nothing. Underneath all three, CODESYS compiles only what a task can reach. An application whose program is not wired to a task therefore builds "0 errors" while containing a syntax error, downloads happily, runs nothing, and leaves the OPC UA server answering every read with a plausible zero. The fourth call appears under Alternatives below, because it is why one of the alternatives lost.

Against that background, three questions. What format the source takes and what is authoritative, where it lives, and what the agentic loop over it looks like.

Decision

The .st files are the source of truth. The .project is a build artefact.

A controller's application is plc/<controller>/ in cndcs-deploy-bench. Structured Text lives in src/*.st, the declarative remainder in project.json (device identifications, task, what to build), and the I/O mapping in iomap.csv in CODESYS's own export format. The .project is generated from those three, is gitignored, and is destroyed and rewritten on every build. A program is two files, NAME.decl.st and NAME.impl.st, because CODESYS holds a POU's declaration and its implementation as two separate documents. Splitting them here means neither half needs a sentinel comment or a parser to find where the other begins.

The IDE is a debugger. It is not an editor.

That rule is enforced. Before regenerating anything, plc-build exports the built project back out through PLCopen XML with declarations_as_plaintext=True, extracts the declaration and body text, and compares it against src/. On a difference it refuses, and prints the differing line from both sides. There is deliberately no override flag. The way past the refusal is to copy the edit into src/, or to delete the .project. Without this the rule would be a convention, and the first person to fix something in the IDE at 2am would lose the fix silently at the next build.

The comparison normalises exactly one thing. A PLCopen export's fileHeader carries a creationDateTime stamped to the tick, so two exports of an unchanged object taken milliseconds apart differ on that line and on no other. A drift check that reported a difference every run would be read as noise within a week.

Structured Text only. Nothing in this tree may require a graphical language. An engagement that needs SFC or LD on a third-party controller reopens this ADR. It is not worked around by committing a .project.

It stays in cndcs-deploy-bench for now. The tag names in the PLC application and the cc100 IOModule channels in manifests/08b-iomodules-phase2.yaml are one contract, and co-location makes a change to both reviewable in one diff. It moves to its own repository when a second controller appears, because a sales-demo PLC or a customer application is a different kind of thing from a bench artefact. It does not go in cloud-native-dcs. A PLC application is configuration for a device the product talks to, and putting it in the shipping tree would put plant logic in the product.

The loop is three targets and five gates.

plc-build      generate + build + offline gates      no hardware
plc-download   log in, download, start, boot app     hardware
plc-verify     secured OPC UA round trip             hardware

plc-download runs plc-build first, so what lands on the controller is what the source tree says. The gates run cheapest first:

Gate Needs Refuses
IOModule parity nothing a manifest channel binding no published symbol; a direction, width or access the two disagree on; a published cycle time that does not match the task
drift CODESYS a built .project that no longer says what src/ says
build clean CODESYS any compiler error, read out of the compiler's own message store
symbols CODESYS a symbol the source publishes that the compiled symbol configuration does not, or publishes with different access
I/O mapping CODESYS a row of iomap.csv that did not bind

The symbol set is held against the IOModule channels that bind it. The gate is hermetic. It reads the .st source and the manifest, and needs neither CODESYS nor the controller. That is why it runs first. A tag renamed on the controller and not in the manifest is a channel that reads a plausible zero forever. The driver reports it Bad once, the operator then sees a number, and nothing in the product is in a position to notice. This is ADR 0076's shape one layer further out. There an address names the driver that answers it. Here a tag names a symbol the controller actually publishes.

Direction is part of the contract, alongside existence. A channel declared input must publish read and refuse readwrite. An input the DCS could write is the DCS writing the process image behind the field signal, which is a lie the server should not be able to tell.

The expected symbol set is derived from the source. The prototype carried a hand-maintained dictionary of thirty-two names and their access next to the ST that declared them. Two lists of the same thing drift, and the one that drifts silently is the one nobody edits. So the {attribute 'symbol'} pragmas are parsed out of the .st files directly. The parser refuses a pragma whose access is not one CODESYS accepts, because a misspelled one compiles clean and publishes nothing.

Every gate is provable red. plc-build -InjectFault build|symbol|drift injects a compile error, a dropped publish pragma that compiles clean, and an edit made "in the IDE". test-plc-iomodule-parity.py injects nine faults into a copy of the real project, and requires each to be refused for the stated reason. On a toolchain with four calls that report success over failure, a gate that has never gone red and a gate that never runs are indistinguishable.

The programming channel is not encrypted, so this workflow is bench-only. The project's is_communication_encrypted is False. plc-download therefore carries the application and the device password in the clear on the OT segment, while the OPC UA path beside it is Basic256Sha256/SignAndEncrypt and refuses anything less. That asymmetry is not defensible in an IEC 62443 posture. The ruling: it is acceptable on an isolated bench segment and nowhere else. Encrypted online communication is a precondition for using this loop against any controller that is not the bench rig, tracked at #1896. plc-download logs the flag on every run. Recording the fact once in a document would be enough only if an asymmetry stayed surprising, and it stops being surprising the second time you fail to notice it.

Corrected 2026-09-02 under #1896. This paragraph originally read "the CC100 reports is_communication_encrypted = False and secure_online_mode = False", and both halves of that sentence were wrong.

report_channel_security() read both names off the project device object, where neither exists. Both getattr calls raised and the except branch logged could not be read, on every run, which is the sentence that got written down here as a reading of False. They live on ScriptCommunicationSettings, reached by device.get_device_communication_settings(). Read there, is_communication_encrypted really is False, so the ruling stands on its own evidence now.

secure_online_mode is not about the transport at all. CODESYS's shipped stub: "whether secure online mode is activated ... If set, user interfaces should prompt the user before an operation is executed which changes the state of the controller." It is an operator-confirmation prompt, and citing it in an IEC 62443 argument was a category error. The device description's runtime_features/disable_secure_online_mode = 0 is about that same prompt. The flag is still logged, labelled, so it cannot be misread the same way twice.

This is the fifth call in this toolchain to report something other than what it did, and the first one that was ours.

Superseded 2026-09-02 under #1896. The programming channel is encrypted, and the loop drives it headlessly. plc-download -Encrypted logs in over TLS, downloads, starts and writes the boot application with no human in the loop. The application runs and TAGS.HEARTBEAT advances 20 counts per second, which is the 50 ms task.

The blocker was never the certificate. No TLS Validator or hostname! is a node name failing to resolve. The validator is looked up by node name, and the certificate's CN is that node name, so a connection addressed at a bare IP has neither of the two things the message names. The IDE, with a full UI and a person at the mouse, refuses identically, which is what ruled out the trust prompt this ADR had assumed. set_gateway_and_device_name was there the whole time.

The bench-only ruling below is therefore lifted for the channel, and replaced by three preconditions, because CODESYS reports all three as that one sentence. Two of them revert on their own, so this is a pre-flight and not a commissioning step: the OT NIC re-classifies Public on every re-plug, and the gateway's UDP binding lives in a working directory the gateway regenerates from a template. The third is the trust, which does not revert.

What is not lifted is the rest of the bench-only posture. A customer controller means a certificate no one here has vouched for, and the honest statement of what the bench proved is "this controller, this fingerprint, this host".

Two things still need a human, stated so that "Claude as the interface to the PLC" has a boundary it declares. Those are the certificate trust ceremony, and any graphical-language work. Everything else in the loop above is drivable headlessly.

Amended 2026-09-02: the trust ceremony needs a human decision. Someone must vouch for a controller's fingerprint, and that is a judgement no script should make for itself. It is recorded in an ordinary Windows certificate store, Cert:\CurrentUser\Controller Certificates. A fingerprint already vouched for can therefore be re-provisioned onto another host with no IDE. Establishing a new one cannot, and that is the boundary.

The first of those is a toolchain property. #1896 established that. online.register_trusts_certificate(device, callback, node_name) hands a script the PLC's X509Certificate2 and its chain and takes a bool back, so a script could answer the trust question by pinning a fingerprint. It is in the reflected API surface and documented in the stub CODESYS ships, :version added: 3.5.17.0. On 3.5.21.50 every form of it raises This functionality is no longer supported!, from a string inside ScriptDriverOnline*.dll. That includes the argument-free unregister_all_trusts_certificate(). The API was shipped, documented, and hollowed out with its surface left standing. It is the sixth such call, and the one that decides whether the encrypted loop can ever be fully headless.

The credential cache was Operations' to rule on, and it is ruled. The device password lives at ~/.config/cndcs/cc100-admin.txt, mode-restricted. An interactive prompt writes it. That prompt has no -Password parameter, so the secret never reaches a command line. All of that follows the ops convention. It is still a plaintext file on a laptop. So engineering filed its requirements at cndcs-operations#77 and did not decide for itself. Those requirements were the scope, the blast radius, and the fact that the download path was unencrypted anyway.

The ruling: the plaintext cache stays, under eight conditions. Recorded in cndcs-operations/docs/token-inventory.md, section "CC100 device credential: the on-disk cache ruling" (cndcs-operations@7ec5fe1). No reader changes: plc-download.py and plc-verify.ps1 go on reading %USERPROFILE%\.config\cndcs\cc100-admin.txt, first line, UTF-8 with no BOM.

Four of the eight conditions bind whoever runs this loop. The interactive prompt stays the only writer. The file stays UTF-8 with no BOM. -Remove runs at the end of a commissioning session, because the cache is session-scoped and not standing state. And the laptop's full-volume encryption is confirmed before the next session. The other four are scope rules, and they draw the boundary this ADR draws. The ruling covers admin on the bench CC100 at 10.10.20.50 and no other controller. The password is treated as disclosed to that segment, so it stays unique to this device and is rotated after anything foreign touches the segment. The cache is one file in the native-Windows profile, because a WSL2 copy would be a second plaintext file that -Remove never reaches.

DPAPI was refused, and the reason is one this tree keeps meeting. A CurrentUser blob decrypts with no prompt for any process running as the owning account. The ACL already restricts the file to that same account. The only boundary DPAPI adds is therefore a second local account, and this laptop has one interactive account. The reader change then costs real ground: IronPython 2.7 cannot decrypt, so the wrapper would hand the plaintext to the download through a process environment that CODESYS.exe and every child it spawns inherits, that any same-user process can read, and that a crash dump captures whole. Encrypting the file at rest would have moved the secret from a one-account ACL into an inherited environment block. DPAPI does close one gap the ACL leaves open, which is someone copying the file off a powered-down disk. That gap goes to full-volume encryption instead, which closes it for every credential on the machine at once and changes no readers.

Per-run Bitwarden was refused on the fact this ADR supplied, which is that the download carried the password across the OT segment in the clear on every run. Anyone on the segment read it off the wire without touching the laptop, and anyone who could read the file was already inside the Windows account. An unlock ceremony on every run of a loop built to remove ceremonies would have hardened the smaller of the two exposures.

The premise under that second refusal moved the same day. The ruling landed at 14:20 UTC. plc-download -Encrypted was proven on the rig five and a half hours later, and the paragraphs above lift the channel restriction. The wire argument is not yet void — the encrypted channel is opt-in, so an ordinary plc-download still carries the password in the clear — but it is now true by a default rather than by a limitation, and the thing keeping the ruling's premise alive is a switch that defaults off.

Operations named this exact reopener: "#1896 landing encryption while the loop moves off the bench segment, because then the wire argument disappears and the file becomes the weakest link." Half of that has happened. The loop has not left the bench segment, so the ruling stands as written and the cache stays. What is worth not discovering later is that the reopener is now one condition away from met rather than two, and that whether -Encrypted should become the default is a live question the closing of #1896 did not settle.

What else reopens it, in Operations' words: a second interactive account on the laptop, a credential with company-wide blast radius being cached this way, or volume encryption turning out to be unavailable on the installed Windows edition.

Alternatives Considered

PLCopen XML as the source of truth (option B in #1892). Rejected, on evidence gathered against CODESYS 3.5.21.50.

Standard PLCopen XML is lossy for exactly the content this codebase treats as load-bearing. Exporting the SCALE GVL with default options produced 2801 bytes. A comment attached to a declaration survived. // volts, both directions became <documentation> on EU_FULL_SCALE. One comment did not survive: the twelve-line block standing on its own above the first declaration, which explains why the analog input and output full-scale counts are two different constants. It was absent. It reappears only with declarations_as_plaintext=True, a 3S proprietary extension carried in addData under http://www.3s-software.com/plcopenxml/interfaceasplaintext. A GVL is itself carried entirely inside another proprietary extension (.../globalvars), so a GVL is not standard PLCopen XML at all.

"The exported XML is the source of truth" therefore resolves to one of two things. Standard PLCopen XML silently drops the reasoning, which disqualifies it. PLCopen XML plus proprietary extensions is the ST text embedded, XML-escaped, inside XML. That is option A with a wrapper, worse diffs (> renders as &gt;, and a whole POU body is one text node), and a format whose portability claim no longer holds.

It is retained for what it is good at. The round trip is sound. Re-importing an export made with declarations_as_plaintext=True and re-exporting produced a byte-identical document once creationDateTime was normalised. That is exactly the property a drift check needs, which is why the drift check uses it.

IEC 61131-10 as the source of truth (option C in #1892). Rejected. #1892 asked for it to be evaluated before committing to A, on the grounds that clean per-POU text plus graphical fidelity would dominate. It was evaluated. It is broken headlessly on this toolchain.

The ScriptEngine has no IEC 61131-10 API at all. export_xml/import_xml (PLCopen) and export_native/import_native (CODESYS's own format) are the only exchange methods on ScriptObject and ScriptProject. The exchange is reachable as a UI command and nothing else: Export IEC61131-10... (3d32bd12-9ebb-4153-94c6-415c4537dfb4, tokens iec6113110 export), whose execute takes untyped *stBatchArguments with no documented grammar.

Driven that way, with prompts suppressed:

  • execute() with no arguments blocked a --noUI process indefinitely. It was killed at 240 seconds.
  • execute(<destination>, "Application") returned None, filed no message in any category, and created the destination file at zero bytes. It stayed at zero bytes across polls at 0, 250 ms, 500 ms, 1 s, 2 s, 4 s and 8 s.
  • The content flushed only when the project was closed, at 3075 bytes, and what it flushed ends mid-element (<Variable name="IOMAP.AI), with no closing tags and no </Project>. It is not well-formed XML. A further 10 seconds after the close changed nothing. Targeting "Device" gave 4096 bytes at the call and 4100 after close, truncated the same way.
  • One argument form did produce a well-formed document. Passing -f before the path made the command take -f itself as the destination. It wrote 246 bytes of header ending in a self-closing <Project ... />. That is a valid, complete, entirely empty export. It is worse than the truncated one, because nothing about it looks wrong.

That is the fourth call in this toolchain reporting success over failure, and the most dangerous of them. A gate written the obvious way would read a zero-byte file, or a truncated one, immediately after a call that returned cleanly. A format whose failed export cannot be told apart from a complete one cannot be a source of truth. The finding is recorded here, which is what #1892 asked for whichever way it came out.

The CODESYS native export. Never seriously in contention, but measured while the other two were. Each object's .export is full of GUIDs and carries a tick Timestamp per object. It is worse than PLCopen XML on every axis that matters here, and proprietary on top.

The generator script (option D, the prototype). Rejected as the standing answer, which its own author said. ST embedded in Python string literals gets no syntax support, no per-POU diff, and no way to tell which POU a change touched. It does not scale past one controller. Its substance is preserved: the generator still exists, and it now reads the .st files it used to contain.

A new cndcs-plc repository, now. Deferred. It is the right home once there are multiple controllers. Today it would separate the PLC tag set from the IOModule manifest that binds it, and that contract is the thing most likely to break. Co-location is what the parity gate exploits.

cloud-native-dcs itself. Rejected outright. A PLC application is not product code.

Consequences

The IDE stops being able to change anything that lasts. People will discover that by being refused, before they can lose work. The refusal names the file and the line on both sides, so the recovery is to copy one line.

A graphical-language requirement now has a defined cost: it reopens this ADR. That is deliberate. Discovering the constraint at a customer, halfway through an engagement, is the outcome the explicit statement exists to prevent.

The parity gate makes the PLC tag set and manifests/ a single reviewable contract. It is also hermetic, so it can move into whatever CI the bench repo grows without dragging CODESYS behind it. It fixes the direction and the width of every channel, which the manifest alone never stated.

A second controller triggers two things at once. It moves this tree out of the bench repo, and it is the first real test of whether project.json generalises. Nothing in the loop is CC100-specific except the contents of plc/cc100-bench/, but that is a claim this ADR has not yet had the chance to falsify.

plc-download remains unusable outside the bench until the programming channel is encrypted. That is a real restriction on the "founder never opens the IDE" goal. For a customer controller, today, someone opens the IDE or accepts a cleartext download. Recording it is what makes the choice a choice.

Superseded 2026-09-02. The channel is encrypted and the loop drives it. What survives of the restriction is narrower and worth stating exactly: a customer controller presents a certificate nobody here has vouched for, and vouching for it is the one step that still needs the IDE. So the IDE is opened once per controller, to make a trust decision, and never again for the build/download loop. That is a different claim from the one this ADR made, and a much smaller hole in the goal.

Issue #1896 settled that on the rig, and the answer is yes. The paragraph above is superseded: plc-download -Encrypted is the loop, and it is headless. Three consequences follow that the original restriction did not anticipate.

The first is that a precondition which reverts is a pre-flight. Two of the three things the encrypted channel needs come back on their own, on a clock nobody controls: a CODESYS upgrade regenerates the gateway's working directory from a template, and Windows re-classifies the OT NIC on every re-plug. A bench that worked in August did not work in September, and nothing announced the change. plc-download -Encrypted checks all three before it connects and names the one that is missing.

The second is that the error message is the expensive part. All three failures, plus a genuinely untrusted certificate, present as No TLS Validator or hostname!. That sentence reads like a certificate problem and is usually a name problem. It cost a wrong hypothesis, a firewall change and a config edit before anyone read it literally. That is why each of the three checks names itself, and why none of them shares a verdict with the others.

The third is that encrypted and headless were never in tension, which is what the original restriction assumed. The trust decision is a decision, made once, and it is recorded somewhere a script can read. Everything downstream of it is ordinary automation. The boundary that survives is a human vouching for a fingerprint, which is where it belongs.

The IEC 61131-10 finding will age. It is a defect in a specific patch level (3.5.21.50). It is also the standard-track format, and a later release that makes the export complete and synchronous would dominate PLCopen XML on portability and should be re-evaluated. The evidence above is written so that it can be re-run.