fix(missions): the first real approval found two bugs the tests could not

Deploying Slice 5 and approving one roster in production broke it twice, in ways
528 green tests had nothing to say about.

**1. `jsonb_set` refuses a scalar.** A mission created through the API without a
`config` stores jsonb `null` — a scalar — and `jsonb_set` fails on it with
"cannot set path in scalar". The guard was `coalesce(config, '{}')`, which
protects against SQL NULL; this is a perfectly good JSON null of the wrong shape,
and coalesce passes it straight through. Every test wrote `'{}'::jsonb` because
that is what a test author types. Production types nothing at all.

**2. The approval was not atomic, and failing halfway is permanent.** The claim
and the mission write were two statements, claim first, so when the write failed
the proposal stood `approved` with nothing applied — and the partial unique index
then makes that state unrecoverable: no other proposal for that mission can ever
be approved. The mission ran solo with `team_engine` still NULL while its
proposal said otherwise.

`approve_and_apply` is now one transaction: claim, write, commit or roll back.
The type guard is `CASE WHEN jsonb_typeof(config) = 'object' THEN config ELSE
'{}'::jsonb END`, which answers the question that was actually being asked.

Both regressions are tested in the shape production had, and both NEGATIVE
CONTROLS were run rather than assumed:

  - restore `coalesce` → `a_roster_applies_to_a_mission_whose_config_is_json_null`
    FAILS with Postgres's own "cannot set path in scalar", the exact production
    error.
  - commit instead of roll back on a failed apply →
    `a_failed_apply_leaves_the_proposal_undecided` FAILS with the proposal stuck
    `approved`.

Worth stating plainly: the API returned 500 for that approval, so this was not
silent to the caller — but the row it left behind claimed the mission had a
roster it never received, and the mission then ran and delivered, which is the
shape that gets believed.

530 tests pass, clippy clean.

Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
Omar Sobh
2026-08-06 16:41:22 -07:00
co-authored by Claude Opus 5
parent aa470091aa
commit 75d09241fb
3 changed files with 170 additions and 27 deletions
@@ -149,3 +149,92 @@ async fn a_proposal_belongs_to_its_workspace() {
"another workspace must not be able to approve this roster"
);
}
/// The bug production found on the FIRST real approval, in the exact shape it
/// had: a mission created through the API with no `config` stores jsonb `null`
/// — a scalar — and `jsonb_set` refuses a scalar with "cannot set path in
/// scalar". `coalesce` does not help, because that guards SQL NULL and this is a
/// perfectly good JSON null of the wrong shape.
#[tokio::test]
async fn a_roster_applies_to_a_mission_whose_config_is_json_null() {
let pool = cm_testkit::test_pool().await;
let ws = workspace(&pool).await;
let m = mission(&pool, ws).await;
// Exactly what `POST /api/missions` stores when the body omits `config`.
sqlx::query("UPDATE missions SET config = 'null'::jsonb WHERE id = $1")
.bind(m)
.execute(&pool)
.await
.unwrap();
let id = Uuid::now_v7();
proposals::insert(&pool, id, m, ws.as_uuid().to_owned(), &roster(), "claude-opus-4-8")
.await
.expect("insert");
let graph = json!({"kind":"pipeline","nodes":[{"id":"n0","role":"implementer","attrs":{}}],"edges":[]});
let applied = proposals::approve_and_apply(
&pool,
id,
m,
ws.as_uuid().to_owned(),
&graph,
None,
None,
)
.await
.expect("apply");
assert!(applied);
let (engine, nodes): (Option<String>, Option<i32>) = sqlx::query_as(
"SELECT team_engine, jsonb_array_length(config->'roster'->'nodes') FROM missions WHERE id = $1",
)
.bind(m)
.fetch_one(&pool)
.await
.unwrap();
assert_eq!(engine.as_deref(), Some("composed"));
assert_eq!(nodes, Some(1), "the roster must actually be on the mission");
}
/// Claim and apply are one decision, so they must commit or fail together. A
/// proposal marked `approved` against a mission that never received the roster
/// is permanent: the partial unique index blocks every later approval, and the
/// mission runs solo while its proposal says otherwise.
#[tokio::test]
async fn a_failed_apply_leaves_the_proposal_undecided() {
let pool = cm_testkit::test_pool().await;
let ws = workspace(&pool).await;
let m = mission(&pool, ws).await;
let id = Uuid::now_v7();
proposals::insert(&pool, id, m, ws.as_uuid().to_owned(), &roster(), "claude-opus-4-8")
.await
.expect("insert");
// A mission id that does not exist in this workspace: the apply half matches
// no row, which is the failure the transaction has to undo.
let err = proposals::approve_and_apply(
&pool,
id,
Uuid::now_v7(),
ws.as_uuid().to_owned(),
&json!({}),
None,
None,
)
.await;
assert!(err.is_err(), "applying to a missing mission must fail: {err:?}");
let rows = proposals::list(&pool, m, ws.as_uuid().to_owned()).await.expect("list");
assert_eq!(
rows[0].status, "proposed",
"the claim must have been rolled back, or this proposal is stuck approved forever"
);
// And it can still be approved properly afterwards.
let graph = json!({"kind":"pipeline","nodes":[{"id":"n0","role":"implementer","attrs":{}}],"edges":[]});
assert!(
proposals::approve_and_apply(&pool, id, m, ws.as_uuid().to_owned(), &graph, None, None)
.await
.expect("apply")
);
}