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 projectsiomodules.jsonandtags.jsonfrom ConfigMapsUnitReconcilermaintains, 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.jsonandhold/state.jsonare keys. The directory in front of them is not. os.MkdirAllis not a write, andos.Statis not a read. Both atomic-save helpers create the parent of the file they are about to write, so countingMkdirAllwould let a directory vouch for a file nothing writes.Statreads 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.