feat(missions): choose the independent validator per mission (#53)

`CLAWMATES_VALIDATOR_MODEL` is deployment-wide, so proving Slice 2 put a second
provider on the critical path of EVERY phase verdict. `cross_provider_judge`
deliberately does not fall back when the independent judge fails — a verdict
quietly produced by a same-family model would claim a property it does not have —
so a z.ai outage makes phases unmeetable rather than merely unverified. That is a
per-mission trade, not a per-deployment one.

`missions.validator_model` (0068), settable at create, with three distinct states
because an empty string and NULL mean opposite things in a nullable text column:

  NULL          use the deployment default
  ''            explicitly NO independent validator — judge with the house model.
                The default must not quietly reinstate independence a mission was
                told to skip.
  'glm:glm-4.7' this spec, subject to the same three refusals as before:
                same-family rejected, unregistered provider rejected, and a failed
                independent judge does not fall back.

Whitespace counts as empty: a column hand-set to " " meant to say nothing.

478 tests pass, clippy clean. Behaviour is unchanged for existing missions — they
have NULL and so keep following the deployment default.
This commit is contained in:
Omar Sobh
2026-08-05 23:07:38 -07:00
parent b17e18aa67
commit c840688adb
4 changed files with 108 additions and 9 deletions
+6 -2
View File
@@ -137,6 +137,9 @@ pub struct NewMission<'a> {
/// rootfs. Deliberately unconstrained in the schema: which images exist is a
/// property of the NODES, not of the database.
pub backend: Option<&'a str>,
/// Independent validator for this mission's verdicts. `None` = deployment
/// default; `Some("")` = explicitly none. See migration 0068.
pub validator_model: Option<&'a str>,
pub phases: Vec<NewMissionPhase>,
}
@@ -165,9 +168,9 @@ pub async fn insert(pool: &PgPool, m: NewMission<'_>) -> Result<Uuid, DbError> {
"INSERT INTO missions
(id, workspace_id, title, template_kind, team_id,
team_template_id, repo_id, schedule, status, description, config,
runtime_kind, target_node_id, backend)
runtime_kind, target_node_id, backend, validator_model)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,'draft',$9,$10,
COALESCE($11,'zeroclaw'),$12,$13)",
COALESCE($11,'zeroclaw'),$12,$13,$14)",
)
.bind(mission_id)
.bind(m.workspace_id)
@@ -182,6 +185,7 @@ pub async fn insert(pool: &PgPool, m: NewMission<'_>) -> Result<Uuid, DbError> {
.bind(m.runtime_kind)
.bind(m.target_node_id)
.bind(m.backend)
.bind(m.validator_model)
.execute(&mut *tx)
.await?;