The credential workflow has an input called strict_providers. On it means
FIFEROUTER_ALLOW_UNCONFIGURED_PROVIDERS=0, which makes a named-but-unset provider
key a startup error rather than a warning — the strict default, because a
dangling key discovered at 3am is worse than one discovered at deploy time.
if [ "${{ inputs.strict_providers }}" = "true" ]; then
printf 'FIFEROUTER_ALLOW_UNCONFIGURED_PROVIDERS=0\n'
fi
Off sends nothing. That reads as harmless and is the bug.
Why nothing is not off
The receiving script merges into .env. It reads KEY=VALUE lines,
replaces the ones it recognises and leaves everything else alone. So a =0
already in the file survives a run with the input off, because nothing arrived
to change it.
Which means the workflow could turn strict mode on and had no way to turn it back off. There was no input that produced the opposite state.
What that cost
Strict mode had been set with FIFEROUTER_INFRA_API_KEY empty — there is no
in-infrastructure backend yet, so the key is unset by design. With
ALLOW_UNCONFIGURED=0, the router refuses to boot without it.
And the receiving script ends with docker compose up -d.
So that run wrote the trap and then triggered it: the router was recreated into
a config it could not start from, compose up failed, and the run exited 1 four
seconds after reporting updated:. The site survived only because the old
container was still running — compose had failed to replace it.
The =0 then sat in .env for three days. Every subsequent run of the
credential workflow, in any configuration, would have recreated the router
into a config that cannot boot. Including the run you would reach for during an
incident.
I told the user twice that running it with strict off would clear the line. It would not have. That was wrong and it took re-reading the send block to see it.
The fix
Write the flag in both directions:
if [ "${{ inputs.strict_providers }}" = "true" ]; then
printf 'FIFEROUTER_ALLOW_UNCONFIGURED_PROVIDERS=0\n'
else
printf 'FIFEROUTER_ALLOW_UNCONFIGURED_PROVIDERS=1\n'
fi
Plus a guard that refuses strict_providers entirely while any provider key
named in providers.yaml is empty — derived from the config rather than
hardcoded, so adding a provider extends the check instead of escaping it.
The shape of the mistake
An option with two settings where one of them is absence.
Absence and a value are only opposites if the receiver has no prior state. The moment the receiver merges, remembers, or defaults, "send nothing" stops meaning "off" and starts meaning "leave whatever is there" — which is a third state, usually undocumented, and reachable only through the history of what was sent before.
The same shape shows up in PATCH semantics, in Terraform's difference between
an empty value and an unset one, and in every configuration system where
"inherit" and "override with the default" look identical from the sending side.
The rule we took from it
A control that can set a state must be able to unset it. If the off position sends nothing, it is not an off position — it is a no-op wearing one, and the system's real state is a function of every run that came before rather than of what the form currently says.