feat(missions): Slice 5 — let a model size the mission's team

`routes/planner.rs` has had Opus proposing rosters since the Master Planner
shipped, and none of it ever reached a mission: the proposal lived in React state
and died with the tab. A mission's shape came from a team template instead —
fixed roles, and every claw minted `claude-sonnet-5` from a literal in
`mint_team_from_template`. That literal is why no mission has ever run more than
one provider.

A roster is `(topology_kind, [(role, backend)])`, which is exactly what the
composed executor already consumes: `Roster::graph` builds a `TopologyGraph` with
the backend in `attrs`, and `MicroVmTurnExecutor` reads `attrs["backend"]` per
node. So a verifier on another provider's rootfs stops being a bolt-on and
becomes a graph node — the correlated-failure break the independent judge exists
for, one layer down.

Three verbs, and the split is the point. **suggest** asks the model and persists
the answer, changing nothing. **decide** approves (writes `config.roster` and
switches the mission to the composed engine) or rejects. A proposal is never
applied on arrival: a model sizing a team is a suggestion about how many VMs to
boot, and this codebase treats model output that costs money as evidence for a
decision, not the decision.

Fail-closed at every seam, because each of these otherwise surfaces much later
and much more expensively:

  - a backend no ONLINE node can boot is refused when PROPOSED, naming the ones
    the fleet actually has. Placement would refuse it too — at launch, after the
    roster was approved and someone believed the mission would run. The model is
    handed that same list in its prompt, so the usual case never arises.
  - an invented `topology_kind` is refused, not defaulted. `parse_topology_kind`
    defaults to hub-spoke, which is right for a template we wrote and wrong for a
    string a model just produced: running a `pipeline` proposal as a hub-and-spoke
    changes what every node sees and nothing would say so.
  - the roster is validated BEFORE it is stored, so a stored proposal is always
    one that could be approved; and again at approval, against the fleet as it is
    then — a node can go offline in between.
  - `MAX_MEMBERS = 6`. Each member is a whole VM, not a subagent, and a model
    asked to size a team proposes twelve happily.

Two properties live in SQL rather than in the handler: at most one approved
roster per mission (partial unique index — two approved rosters are two answers
to "what shape is this mission", and the executor reads one field), and
decide-once (`WHERE status = 'proposed'`, so a double-clicked approve claims
nothing the second time). Both tested against a real database, including that the
second approval is refused by Postgres rather than merely losing a race.

NEGATIVE CONTROL, run rather than assumed: with the roster preference removed
from `composed_graph`, `an_approved_roster_outranks_the_template` FAILS — 3 nodes
from the template instead of the roster's 2. A stored roster that is silently
ignored at launch is precisely the shape this project keeps paying for.

Not closed: per-role models for CLAWS. `template_roles` has no model column, so a
ZeroClaw team still mints one model for every role. The literal is now a named
constant that says so and points at the roster path, rather than sitting inline
where nobody reads it.

527 tests pass, clippy clean. Migration 0070. Not yet exercised against the
deployed stack — the route has never been called with a live model.

Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
Omar Sobh
2026-08-06 16:25:34 -07:00
co-authored by Claude Opus 5
parent abb97e6f03
commit 1797669296
10 changed files with 1121 additions and 1 deletions
@@ -0,0 +1,151 @@
//! A mission may have many proposals and at most one approved roster.
//!
//! Both properties are enforced in SQL rather than in the handler, and both
//! matter for the same reason: the composed executor reads ONE field for what
//! shape a mission is, so a second approval would silently win by being written
//! last.
use cm_db::repo::mission_team_proposals as proposals;
use cm_domain::WorkspaceId;
use serde_json::json;
use uuid::Uuid;
async fn workspace(pool: &sqlx::PgPool) -> WorkspaceId {
let ws = cm_domain::Workspace {
id: WorkspaceId::new(),
name: "Roster".into(),
plan: "team".into(),
};
cm_db::repo::workspaces::insert(pool, &ws).await.expect("workspace");
ws.id
}
/// A mission row to hang proposals off — `mission_id` is a real FK.
async fn mission(pool: &sqlx::PgPool, ws: WorkspaceId) -> Uuid {
let id = Uuid::now_v7();
sqlx::query(
"INSERT INTO missions (id, workspace_id, title, template_kind, status, schedule, config)
VALUES ($1, $2, 'roster test', 'research_and_code', 'draft', '{}'::jsonb, '{}'::jsonb)",
)
.bind(id)
.bind(ws.as_uuid())
.execute(pool)
.await
.expect("insert mission");
id
}
fn roster() -> serde_json::Value {
json!({
"topology_kind": "pipeline",
"members": [
{"role": "implementer"},
{"role": "verifier", "backend": "kimi"}
]
})
}
/// The whole point of persisting: a proposal is a record, not a click. It
/// arrives `proposed`, applied to nothing.
#[tokio::test]
async fn a_proposal_arrives_undecided_and_is_listed() {
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");
let rows = proposals::list(&pool, m, ws.as_uuid().to_owned()).await.expect("list");
assert_eq!(rows.len(), 1);
assert_eq!(rows[0].status, "proposed");
assert_eq!(rows[0].author_model, "claude-opus-4-8");
assert!(rows[0].decided_at.is_none());
assert_eq!(rows[0].roster["members"][1]["backend"], "kimi");
}
/// Deciding twice must not decide twice. A double-clicked approve, or a retried
/// request, would otherwise re-apply a roster to a mission that has moved on.
#[tokio::test]
async fn only_the_first_decision_counts() {
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");
let first = proposals::decide(&pool, id, ws.as_uuid().to_owned(), "approved", None, None)
.await
.expect("decide");
assert!(first, "the first approval must claim the proposal");
let second = proposals::decide(&pool, id, ws.as_uuid().to_owned(), "rejected", None, None)
.await
.expect("decide");
assert!(!second, "a decided proposal must not be re-decided");
let rows = proposals::list(&pool, m, ws.as_uuid().to_owned()).await.expect("list");
assert_eq!(rows[0].status, "approved", "and the first decision stands");
assert!(rows[0].decided_at.is_some());
}
/// At most one approved roster per mission, enforced by a partial unique index.
/// Two approved proposals are two answers to "what shape is this mission".
#[tokio::test]
async fn a_mission_cannot_have_two_approved_rosters() {
let pool = cm_testkit::test_pool().await;
let ws = workspace(&pool).await;
let m = mission(&pool, ws).await;
let a = Uuid::now_v7();
let b = Uuid::now_v7();
for id in [a, b] {
proposals::insert(&pool, id, m, ws.as_uuid().to_owned(), &roster(), "claude-opus-4-8")
.await
.expect("insert");
}
assert!(proposals::decide(&pool, a, ws.as_uuid().to_owned(), "approved", None, None)
.await
.expect("approve a"));
// The second approval must be REFUSED by the database, not merely lose a
// race in the handler.
let second = proposals::decide(&pool, b, ws.as_uuid().to_owned(), "approved", None, None).await;
assert!(second.is_err(), "a second approved roster was allowed: {second:?}");
// Rejecting it is still fine — the constraint is on approvals only, and the
// ones a human turned down are the record of what the planner gets wrong.
assert!(proposals::decide(&pool, b, ws.as_uuid().to_owned(), "rejected", Some("too many VMs"), None)
.await
.expect("reject b"));
let rows = proposals::list(&pool, m, ws.as_uuid().to_owned()).await.expect("list");
assert_eq!(rows.len(), 2, "a rejected proposal is kept, not deleted");
assert!(rows.iter().any(|r| r.status == "rejected" && r.note.as_deref() == Some("too many VMs")));
}
/// Another workspace's proposal is not visible and not decidable. Every read
/// here is scoped, and this is the test that keeps it that way.
#[tokio::test]
async fn a_proposal_belongs_to_its_workspace() {
let pool = cm_testkit::test_pool().await;
let ws = workspace(&pool).await;
let other = 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");
assert!(proposals::get(&pool, id, other.as_uuid().to_owned()).await.expect("get").is_none());
assert!(proposals::list(&pool, m, other.as_uuid().to_owned()).await.expect("list").is_empty());
assert!(
!proposals::decide(&pool, id, other.as_uuid().to_owned(), "approved", None, None)
.await
.expect("decide"),
"another workspace must not be able to approve this roster"
);
}