fix(#3892): a corrupt power-intent row is an error, not an absent one

`PowerStore::get` ended `Ok(row.and_then(|s| Wanted::parse(&s)))`, so a
row holding an unparseable value collapsed into the same `Ok(None)` as
no row at all. Its doc comment stated the narrower case as the whole one
("`None` when the agent has no row yet"), which is what kept the gap
invisible.

The damage is not that a caller reads the wrong value. `get_or_seed`
takes `None` to mean "never seeded" and writes
`Wanted::from_running(running)` — so a corrupted row silently
**overwrote itself** with whatever the container happened to be doing at
that moment, which is precisely the inference an authoritative intent
store exists to prevent. Its own comment says the DB is authoritative
once seeded.

The error arm was already in the signature; this makes it reachable.
Every caller already handles `Result`: the two `get_or_seed` paths
propagate, so a corrupt row now fails that agent's boot reconcile
instead of erasing itself, and `workers::wanted` already skips an agent
whose intent could not be read.

The second test is the one that matters — it asserts the row still holds
its original bytes afterwards, because the overwrite, not the read, was
the bug. Both tests carry a valid and an absent row alongside, so
neither can pass by breaking `get` for everything.

Found by argus reviewing #3891, which reads this store but does not
write it.
This commit is contained in:
atlas 2026-09-01 13:25:42 +02:00 committed by mara
commit 978e66790c

View file

@ -109,6 +109,13 @@ impl PowerStore {
/// Read an agent's intent. `None` when the agent has no row yet
/// (callers seed from observed state via [`Self::get_or_seed`]).
///
/// A row that exists but holds an unparseable value is an **error**, not
/// `None`. The two used to collapse, and the damage was not that a caller
/// read the wrong value: [`Self::get_or_seed`] takes `None` to mean "never
/// seeded" and writes what the container is *currently doing*, so a
/// corrupted row silently overwrote itself with the observed state this
/// store exists to overrule.
pub fn get(&self, agent: &str) -> Result<Option<Wanted>> {
let conn = self.conn.lock().expect("agent_power mutex poisoned");
let row: Option<String> = conn
@ -119,7 +126,12 @@ impl PowerStore {
)
.optional()
.context("select agent_power")?;
Ok(row.and_then(|s| Wanted::parse(&s)))
match row {
None => Ok(None),
Some(stored) => Wanted::parse(&stored).map(Some).with_context(|| {
format!("agent_power row for {agent} holds an unknown intent {stored:?}")
}),
}
}
/// Write an agent's intent (last-writer-wins, synchronous at
@ -178,6 +190,60 @@ mod tests {
);
}
/// Write a value `set` cannot produce, the way corruption would.
fn write_raw(store: &PowerStore, agent: &str, wanted: &str) {
let conn = store.conn.lock().expect("agent_power mutex poisoned");
conn.execute(
"INSERT INTO agent_power (agent, wanted, updated_at) VALUES (?1, ?2, 0)",
params![agent, wanted],
)
.expect("insert raw row");
}
/// A row that exists but does not parse is an error, not an absent one.
/// The two rows beside it are the control: this must not pass by breaking
/// `get` for everything.
#[test]
fn an_unparseable_row_is_an_error_not_an_absent_one() {
let store = PowerStore::open_in_memory().expect("open");
store.set("valid", Wanted::Up).expect("set");
write_raw(&store, "corrupt", "sideways");
assert_eq!(store.get("valid").expect("valid reads"), Some(Wanted::Up));
assert_eq!(store.get("missing").expect("absent reads"), None);
let err = store
.get("corrupt")
.expect_err("corrupt must not read as absent");
assert!(
format!("{err:#}").contains("sideways"),
"the error should name the offending value: {err:#}"
);
}
/// The damage the error arm exists to prevent: `get_or_seed` treats
/// `None` as "never seeded" and writes observed state, so a corrupt row
/// used to overwrite itself with whatever the container was doing.
#[test]
fn a_corrupt_row_is_not_reseeded_from_observed_state() {
let store = PowerStore::open_in_memory().expect("open");
write_raw(&store, "corrupt", "sideways");
assert!(store.get_or_seed("corrupt", true).is_err());
let stored: String = store
.conn
.lock()
.expect("agent_power mutex poisoned")
.query_row(
"SELECT wanted FROM agent_power WHERE agent = ?1",
params!["corrupt"],
|r| r.get(0),
)
.expect("row still there");
assert_eq!(stored, "sideways", "the corrupt row must be left alone");
}
#[test]
fn get_set_roundtrip_and_seed() {
let store = PowerStore::open_in_memory().expect("open");