Your config editor saves the template, not the file that runs
A settings editor returned ok on every save while the scheduler read a different copy of the file. The bug class, the invariant, and a five-minute stat check.
Every save of the heartbeat directive returned {"ok":true}. Not one of them changed what the scheduled agent did. I blamed the model for a week.
{"ok":true} on every save, and the June directive kept running
Vodou has a scheduled heartbeat: the engine wakes on a timer, reads a directive file called HEARTBEAT.md, and posts it to the gateway as a prompt. The Console has an editor for that directive. You edit, you save, the endpoint answers ok, the next heartbeat runs. Except it ran the old text. I rewrote the directive three times, got three identical replies, and decided the model was ignoring instructions.
The model was following instructions. It was following the June ones.
One PUT at index.ts:4221, one reader in the runtime workspace, zero errors
The editor’s two endpoints live in MCP-servers/Vodou-Console/src/index.ts at lines 4204 through 4227. Both build the same path, and the comment above them told the truth the whole time:
// --- Heartbeat directive — read/write HEARTBEAT.md template ---
app.put('/api/heartbeat/directive', (req, res) => {
const { content } = req.body;
const tplPath = path.join(getProjectRoot(), 'templates', 'HEARTBEAT.md');
fs.writeFileSync(tplPath, content, 'utf-8'); // line 4221
res.json({ ok: true });
});
It writes templates/HEARTBEAT.md. The engine’s scheduler never opens that file. It reads the copy in the runtime workspace, and it skips the heartbeat entirely if that copy is empty or comments-only. The GET reads the template too, so the editor was internally consistent: you saw what you saved, every time, in a file nothing executed.
How did two copies exist? The release packer loops over nine names in the templates directory and copies each into the runtime workspace, so a fresh install has byte-identical seed and live copies. The seed is the source of truth for new installs. The live copy is what runs. Someone (me) later hand-edited the live copy on June 11 and never touched the seed again. stat told the story before any code did: template dated May 12, workspace copy dated June 11. And the twist that made it sting: the older template was the better directive. The June hand-edit had regressed it. I spent a week polishing a file nobody read while the worse version ran on a timer.
A second console copied from the first ships the identical handler pair. Copy-paste carried the bug to a place the fix would not automatically reach.
The fix is one function that both endpoints resolve through, plus a PUT that refuses to say ok until it has re-read what it wrote:
function heartbeatDirectivePath(): string {
return path.join(runtimeWorkspaceRoot(), 'HEARTBEAT.md');
}
app.put('/api/heartbeat/directive', (req, res) => {
const p = heartbeatDirectivePath();
fs.writeFileSync(p, req.body.content, 'utf-8');
if (fs.readFileSync(p, 'utf-8') !== req.body.content) {
return res.status(500).json({ error: 'write did not land at ' + p });
}
res.json({ ok: true, path: p });
});
The path in the response is not decoration. It is the receipt that would have ended this in a minute.
The class: a seed copied into a per-instance copy, and a writer that kept the seed’s address
Any system with a source-of-truth config and a derived per-instance copy has two files with the same name and one address in the editor’s head. Kubernetes ConfigMaps versus the volume-mounted copy a pod actually opened. .env.example versus .env. Agent frameworks with a repo-level prompt file and a workspace-scoped one. Django settings modules with a cached override layer. The editor writes whichever path its author was looking at on the day, and the runtime reads the other one, with no error anywhere, because both writes succeed.
Every writer of a runtime-read config resolves its path through the same function the reader uses, and a successful write is confirmed by reading it back through that path. That is checkable: grep the writers, grep the readers, count the distinct path expressions. More than one is the bug, whether or not it has fired yet.
The standard advice for “I changed the config and the service ignores it” is restart the service, then check file permissions. The 2012 Stack Overflow answer on a Windows 7 service not reading its config file gets closer than most: the culprit was UAC data redirection quietly writing edits to a VirtualStore shadow copy while the service read Program Files. That answer is right that the edit landed somewhere real. It misses that the shadow copy does not have to come from the OS. Mine came from my own release packer, and my own editor wrote to the seed on purpose. Restarting would have done nothing, and it did nothing, several times. The Grafana forum thread is the mirror image: OAuth configured through the GUI, absent from grafana.ini, and no path from one store to the other. Same shape, opposite direction, same silence.
Stat the file your process opened, then stat the file your editor wrote
Run this against your own stack, with your own config name. Five minutes.
# 1. What does the running process actually open?
sudo lsof -p "$PID" | grep -i 'config.yaml' # or strace -e openat on Linux
# 2. Drop a marker, then save through the EDITOR (UI, CLI, PUT), not with vim.
touch /tmp/mark
curl -X PUT localhost:8080/api/settings -d '{"content":"SENTINEL-1725900000"}'
# 3. Which files with that name changed after the marker?
find / -name 'config.yaml' -newer /tmp/mark 2>/dev/null
# 4. Does the sentinel exist in the file from step 1?
grep -c SENTINEL-1725900000 "$(lsof -p "$PID" | awk '/config.yaml/{print $NF}')"
Passing output: step 3 prints exactly one path, it matches step 1, and step 4 prints 1. Failing output: step 3 prints a path step 1 never mentioned, step 4 prints 0, and the editor said ok. If the process only reads the file at boot, lsof will show nothing; use strace -e openat across a restart instead, and note that the restart is part of the reproduction, not the fix.
Rule for any codebase: a config writer that cannot print the path the runtime reads has not saved anything. Make it print the path, and make the reader own that path.