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
@@ -113,6 +113,74 @@ pub async fn get(
))
}
/// Approve a proposal AND apply it to its mission, atomically.
///
/// One transaction, because the two halves are one decision. The first version
/// claimed the proposal and then wrote the mission in two statements, and
/// production found the hole on the first real approval: the write failed, the
/// claim stood, and the mission was left with no roster while its proposal said
/// `approved` — a state the partial unique index then makes permanent, since no
/// second proposal for that mission can ever be approved.
///
/// Returns false when the proposal was already decided (a double-clicked
/// approve), in which case nothing is written.
pub async fn approve_and_apply(
pool: &PgPool,
id: Uuid,
mission_id: Uuid,
workspace_id: Uuid,
graph: &Value,
note: Option<&str>,
decided_by: Option<Uuid>,
) -> Result<bool, DbError> {
let mut tx = pool.begin().await?;
let claimed = sqlx::query(
"UPDATE mission_team_proposals
SET status = 'approved', note = $3, decided_at = now(), decided_by = $4
WHERE id = $1 AND workspace_id = $2 AND status = 'proposed'",
)
.bind(id)
.bind(workspace_id)
.bind(note)
.bind(decided_by)
.execute(&mut *tx)
.await?
.rows_affected();
if claimed != 1 {
tx.rollback().await?;
return Ok(false);
}
// `jsonb_set` REFUSES a scalar, and a mission created without a `config`
// stores jsonb `null` — a scalar. `coalesce` does not help: it guards SQL
// NULL, and this is a JSON null, which is a perfectly good non-NULL value of
// the wrong shape. Production hit this on the first real approval with
// "cannot set path in scalar".
let applied = sqlx::query(
"UPDATE missions
SET config = jsonb_set(
CASE WHEN jsonb_typeof(config) = 'object' THEN config ELSE '{}'::jsonb END,
'{roster}', $3::jsonb, true),
team_engine = 'composed',
updated_at = now()
WHERE id = $1 AND workspace_id = $2",
)
.bind(mission_id)
.bind(workspace_id)
.bind(graph)
.execute(&mut *tx)
.await?
.rows_affected();
if applied != 1 {
tx.rollback().await?;
return Err(DbError::NotFound);
}
tx.commit().await?;
Ok(true)
}
/// Record a decision. Only a `proposed` row may be decided, so approving twice
/// — a double-click, a retried request — cannot re-apply a roster to a mission
/// that has since moved on. Returns whether this call was the one that decided.