Skip to content

ADR 0077: Persisted state has a writer, or it has no meaning

Status: Accepted Date: 2026-08-21 Issue: #1748 Related: ADR 0006 (the autonomy posture this state serves), ADR 0065 (a removed capability still offered by the layers around it), ADR 0076 (the primary driver this path was handed)

Context

The unit runtime kept two independent copies of the same thing, and only one of them was alive.

NetworkManager persists every deployed program to {DataDir}/networks/all.json on each deploy and each stop. RestoreAll reads that file at startup and redeploys each program as a cyclic scan. It is constructed with the Adapter, so a block's address is routed by resolveDriver to the IOModule driver that answers it.

ReplayLastNetwork read a different file, {DataDir}/last-program.json. It loaded the one program that file held and ran it for four scan cycles before cancelling. It was constructed with a.driver, the runtime's primary driver. That driver does no routing. It takes the whole prefixed string as an address. Every pod this product creates starts it as an unconfigured simulation driver. An AI on the replay path would therefore read granulation-demo-sim:analog.0 as an address the simulation store has never seen, and be answered 0.0 at quality Good with no error. That is ADR 0076's hazard, reached by a second road. The answer itself changed later: #1955 made the simulation driver report Bad for an address it holds nothing for, which is ADR 0074's fifth amendment.

#1748 was filed about exactly that, and proposed passing a in place of a.driver. Tracing the path further found the premise false in a way that changes the answer.

The file has had no writer since 2026-05-11. SaveNetwork was called from one place, the legacy oneshot deploy route. Commit 95849b90, the Phase 3 route cutover for #361, deleted that route and kept the loader, the replay, and a migration note in architecture.md promising that "the next SaveNetwork writes the new filename." Nothing has written either filename since.

Nothing went red, and nothing could have. LoadNetwork answered (nil, nil) and the runtime logged no persisted network found, skipping replay. That line is also exactly what a healthy first boot on a fresh node logs. The bench node shows both facts in one second: at 17:12:41 the pod logs that line, and in the same second RestoreAll restores three networks, correctly routed, as continuous scans. The autonomy the dead path claimed was being delivered the whole time by the live one.

go vet and unused see nothing here, because both halves are live exported functions with callers. What is missing is a link no compiler follows, between the code that writes a file and the code that reads it back.

Decision

Delete the dead path. ReplayLastNetwork, SaveNetwork, LoadNetwork, internal/adapter/persist.go and the legacy-filename fallback are removed. The documentation that described them now describes RestoreAll.

Check the pairing, per file. For every literal path the adapter tree reads out of its data directory, some non-test code in the tree writes that same path, or scripts/.runtime-state-files.tsv carries a verdict naming who does instead. make lint-runtime-state-files reads it. There are two verdicts.

  • external, written by something outside this repository. The runtime's two ConfigMap inputs are the real cases. The kubelet projects iomodules.json and tags.json from ConfigMaps UnitReconciler maintains, and each is read at both its directory path and its pre-directory one.
  • gap, an open issue number, for a pairing known to be broken and tracked.

There is no allowlist. A third verdict meaning "do not ask" is the state last-program.json was already in.

Four things the gate has to get right. A simpler version got each of them wrong first.

  • A path it cannot resolve is reported. Passing what it could not read would put the gate in the state it exists to detect. Paths resolve through constants, reassignments, concatenation, helper methods that return a filepath.Join, and function parameters traced to their call sites, because the tree uses all of those.
  • The key is the literal tail. Every data-directory path begins with a variable, so the gate matches on what follows the last component it could not read. networks/all.json and hold/state.json are keys. The directory in front of them is not.
  • os.MkdirAll is not a write, and os.Stat is not a read. Both atomic-save helpers create the parent of the file they are about to write, so counting MkdirAll would let a directory vouch for a file nothing writes. Stat reads no bytes. The runtime uses one to choose between two candidate paths before reading whichever it picked.
  • A tree it found no file access in fails. A gate that analysed nothing reports the same green as a gate that analysed everything.

Alternatives Considered

Route the replay and keep it. This is the one-line fix the issue proposed, and it was rejected on what it would newly enable. A correctly routed replay is the first time that code would reach a real device. What it would reach with is a program persisted before May 2026, four scans of it, in the moments before RestoreAll deploys the current programs over the top. In Failover mode the lease fence drops those writes. In Autonomy mode there is no fence, and Autonomy mode is the deployment where edge replay is supposed to matter.

Restore the writer as well, so the feature genuinely works. Rejected as building a second copy of networks/all.json that is always a stale subset of it. Both files would be restored at startup, one over the other, and the singleton would add no state the multi-network file does not already carry.

Leave the path alone and document it as vestigial. Rejected because the promise was load-bearing. Five documents described this file as the mechanism of edge autonomy, and one of them told an upgrading operator to rely on it. A comment saying otherwise would not have reached any of them.

Consequences

An edge node's local autonomy is unchanged in behaviour. It now has one implementation, where before it had one implementation and one decoy. A restarted pod redeploys every program it was running, from disk, as a continuous scan, with each block's address routed to the driver that answers it, and with output writes fenced until the node holds its lease.

A reader whose writer goes away is now a failing gate. It used to be a log line whose other meaning is "nothing to do". The gate found a second instance on its first run against the real tree. LoadTagMap was an exported reader of tags.json that no shipped code called. New reads that file inline instead. It also needs the raw bytes for the change checksum. LoadTagMap was removed. Its tests moved onto ParseTagMap, which is what the runtime actually parses with.

FBHandler no longer holds a driver. It was constructed with adapter.driver and never used it. Every program it deploys runs under the NetworkManager, which holds the Adapter. With that field and ReplayLastNetwork both gone, no code outside the Adapter holds the primary driver at all.

architecture.md § Local Autonomy, backup-recovery.md, compliance/iec61131-3.md, ADR 0006 and ADR 0009 each described the singleton file. They describe networks/all.json now. The Phase 3 migration note that promised a writer is replaced by an account of what actually carried the upgrade.