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]>
210 lines
6.3 KiB
Rust
210 lines
6.3 KiB
Rust
//! Model-authored mission rosters, and whether a human accepted them.
|
|
//!
|
|
//! See `migrations/0070_mission_team_proposals.sql` for why a proposal is
|
|
//! persisted rather than applied on arrival.
|
|
|
|
use crate::DbError;
|
|
use serde_json::Value;
|
|
use sqlx::PgPool;
|
|
use time::OffsetDateTime;
|
|
use uuid::Uuid;
|
|
|
|
#[derive(Debug, Clone, serde::Serialize)]
|
|
pub struct MissionTeamProposal {
|
|
pub id: Uuid,
|
|
pub mission_id: Uuid,
|
|
pub roster: Value,
|
|
pub author_model: String,
|
|
pub status: String,
|
|
pub note: Option<String>,
|
|
#[serde(with = "time::serde::rfc3339")]
|
|
pub created_at: OffsetDateTime,
|
|
#[serde(with = "time::serde::rfc3339::option")]
|
|
pub decided_at: Option<OffsetDateTime>,
|
|
}
|
|
|
|
pub async fn insert(
|
|
pool: &PgPool,
|
|
id: Uuid,
|
|
mission_id: Uuid,
|
|
workspace_id: Uuid,
|
|
roster: &Value,
|
|
author_model: &str,
|
|
) -> Result<(), DbError> {
|
|
sqlx::query(
|
|
"INSERT INTO mission_team_proposals
|
|
(id, mission_id, workspace_id, roster, author_model)
|
|
VALUES ($1, $2, $3, $4, $5)",
|
|
)
|
|
.bind(id)
|
|
.bind(mission_id)
|
|
.bind(workspace_id)
|
|
.bind(roster)
|
|
.bind(author_model)
|
|
.execute(pool)
|
|
.await?;
|
|
Ok(())
|
|
}
|
|
|
|
/// Every proposal for a mission, newest first. Rejected ones are included on
|
|
/// purpose: what a human turned down is the only record of what the planner
|
|
/// gets wrong.
|
|
pub async fn list(
|
|
pool: &PgPool,
|
|
mission_id: Uuid,
|
|
workspace_id: Uuid,
|
|
) -> Result<Vec<MissionTeamProposal>, DbError> {
|
|
let rows = sqlx::query_as::<_, (Uuid, Uuid, Value, String, String, Option<String>, OffsetDateTime, Option<OffsetDateTime>)>(
|
|
"SELECT id, mission_id, roster, author_model, status, note, created_at, decided_at
|
|
FROM mission_team_proposals
|
|
WHERE mission_id = $1 AND workspace_id = $2
|
|
ORDER BY created_at DESC",
|
|
)
|
|
.bind(mission_id)
|
|
.bind(workspace_id)
|
|
.fetch_all(pool)
|
|
.await?;
|
|
Ok(rows
|
|
.into_iter()
|
|
.map(
|
|
|(id, mission_id, roster, author_model, status, note, created_at, decided_at)| {
|
|
MissionTeamProposal {
|
|
id,
|
|
mission_id,
|
|
roster,
|
|
author_model,
|
|
status,
|
|
note,
|
|
created_at,
|
|
decided_at,
|
|
}
|
|
},
|
|
)
|
|
.collect())
|
|
}
|
|
|
|
pub async fn get(
|
|
pool: &PgPool,
|
|
id: Uuid,
|
|
workspace_id: Uuid,
|
|
) -> Result<Option<MissionTeamProposal>, DbError> {
|
|
let row = sqlx::query_as::<_, (Uuid, Uuid, Value, String, String, Option<String>, OffsetDateTime, Option<OffsetDateTime>)>(
|
|
"SELECT id, mission_id, roster, author_model, status, note, created_at, decided_at
|
|
FROM mission_team_proposals
|
|
WHERE id = $1 AND workspace_id = $2",
|
|
)
|
|
.bind(id)
|
|
.bind(workspace_id)
|
|
.fetch_optional(pool)
|
|
.await?;
|
|
Ok(row.map(
|
|
|(id, mission_id, roster, author_model, status, note, created_at, decided_at)| {
|
|
MissionTeamProposal {
|
|
id,
|
|
mission_id,
|
|
roster,
|
|
author_model,
|
|
status,
|
|
note,
|
|
created_at,
|
|
decided_at,
|
|
}
|
|
},
|
|
))
|
|
}
|
|
|
|
/// 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.
|
|
pub async fn decide(
|
|
pool: &PgPool,
|
|
id: Uuid,
|
|
workspace_id: Uuid,
|
|
status: &str,
|
|
note: Option<&str>,
|
|
decided_by: Option<Uuid>,
|
|
) -> Result<bool, DbError> {
|
|
let done = sqlx::query(
|
|
"UPDATE mission_team_proposals
|
|
SET status = $3, note = $4, decided_at = now(), decided_by = $5
|
|
WHERE id = $1 AND workspace_id = $2 AND status = 'proposed'",
|
|
)
|
|
.bind(id)
|
|
.bind(workspace_id)
|
|
.bind(status)
|
|
.bind(note)
|
|
.bind(decided_by)
|
|
.execute(pool)
|
|
.await?
|
|
.rows_affected();
|
|
Ok(done == 1)
|
|
}
|