Skip to main content

car_server_core/coder/
multiplayer.rs

1//! Multiplayer development: a work item moves **Build → Improve → Polish**
2//! through different developers before it may merge. Design and rationale:
3//! `docs/proposals/multiplayer-development.md`.
4//!
5//! This is a layer *on top of* coder sessions, not a change to them. Each stage
6//! is an ordinary `coder.*` session, run on its owner's own machine with the
7//! engine they chose. What this module adds is the hand-off:
8//!
9//! - A **work item** record, committed at `.car/multiplayer/<id>.json` on the
10//!   branch `car/mp/<id>` in the team's shared git remote. The artifact carries
11//!   the record; nothing else travels between stages. The record has no
12//!   free-text field except the intent fixed at Build, and deserializes with
13//!   `deny_unknown_fields`, so a hand-off cannot smuggle the previous owner's
14//!   reasoning even if someone wants it to.
15//! - [`publish`], which turns a finished stage session into the next commit on
16//!   that branch after checking the rules the concept depends on: one stage per
17//!   account, each stage starts from the previous stage's tip, the contract only
18//!   grows, and a stage never edits the record itself.
19//! - [`merge_check`], which re-verifies the recorded history and re-runs the
20//!   final contract before producing a squash branch for the team's normal
21//!   pull-request flow. CAR never pushes to `main`.
22//!
23//! **Eligibility is advisory.** `account_id` is the stage owner's own
24//! `auth.snapshot` account, written into a git file that anyone with push
25//! access could edit. The rules catch honest mistakes; making them unforgeable
26//! needs server-attested stage receipts (the proposal's slice 5).
27
28use std::collections::HashSet;
29use std::io::Write as _;
30use std::path::{Path, PathBuf};
31use std::process::{Command, Stdio};
32use std::sync::Arc;
33
34use serde::{Deserialize, Serialize};
35use serde_json::{json, Value};
36use sha2::{Digest, Sha256};
37
38use super::contract::{CheckResult, OutcomeContract};
39use super::session::{CoderSession, CoderState, EventSink};
40use crate::handler::JsonRpcMessage;
41use crate::session::{ClientSession, ServerState};
42
43/// Where the record lives inside the tree.
44pub const RECORD_DIR: &str = ".car/multiplayer";
45/// Branch prefix for work items, locally and on the remote.
46pub const BRANCH_PREFIX: &str = "car/mp/";
47/// Wire version of [`WorkItem`]. The record rejects unknown fields (so a
48/// hand-off cannot smuggle prose) and readers require an exact match, so ANY
49/// field change — even an additive optional one — must bump this: a teammate on
50/// an older CAR then gets a clear "schema version" error instead of an
51/// unreadable item.
52pub const SCHEMA_VERSION: u32 = 1;
53
54const COMMITTER: [&str; 6] = [
55    "-c",
56    "user.name=car-multiplayer",
57    "-c",
58    "user.email=multiplayer@parslee.ai",
59    // A daemon has no one to answer a pinentry prompt.
60    "-c",
61    "commit.gpgSign=false",
62];
63
64/// One stage of a work item.
65#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
66#[serde(rename_all = "snake_case")]
67pub enum Stage {
68    Build,
69    Improve,
70    Polish,
71    /// A further independent pass after Polish, for work whose discovery rate
72    /// has not settled.
73    Extra,
74}
75
76impl Stage {
77    pub fn as_str(self) -> &'static str {
78        match self {
79            Self::Build => "build",
80            Self::Improve => "improve",
81            Self::Polish => "polish",
82            Self::Extra => "extra",
83        }
84    }
85
86    /// The stage that follows `completed` finished stages.
87    pub fn after(completed: usize) -> Self {
88        match completed {
89            0 => Self::Build,
90            1 => Self::Improve,
91            2 => Self::Polish,
92            _ => Self::Extra,
93        }
94    }
95}
96
97/// Runtime-collected measures of what a stage changed. None of it is the
98/// model's self-report.
99#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
100#[serde(deny_unknown_fields)]
101pub struct StageSignals {
102    pub files_changed: u64,
103    pub lines_added: u64,
104    pub lines_removed: u64,
105    /// Checks this stage added to the locked contract.
106    pub checks_added: u64,
107}
108
109/// One completed stage.
110#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
111#[serde(deny_unknown_fields)]
112pub struct StageRecord {
113    pub stage: Stage,
114    /// The stage owner's account. Advisory — see the module docs.
115    pub account_id: String,
116    /// The coder session that did the work, on the owner's machine. `None`
117    /// for a stage submitted from outside CAR (`multiplayer.submit_stage`).
118    #[serde(default, skip_serializing_if = "Option::is_none")]
119    pub session_id: Option<String>,
120    /// The engine that session resolved to (`native`, `external:claude-code`,
121    /// …), or `external-unmanaged` for a submitted stage.
122    pub engine: String,
123    #[serde(default, skip_serializing_if = "Option::is_none")]
124    pub model: Option<String>,
125    /// The commit the stage started from: the previous stage's tip, or for
126    /// Build the commit its worktree was provisioned at.
127    pub base_commit: String,
128    /// The stage's own work commit — the approved coder branch — or
129    /// `base_commit` for an accepted no-change finding.
130    pub result_commit: String,
131    /// The stage accepted a "no change was needed" finding instead of a diff.
132    pub no_change: bool,
133    /// [`contract_hash`] of the contract as this stage left it.
134    pub contract_hash: String,
135    pub finished_at: u64,
136    pub signals: StageSignals,
137    /// Metered inference spend of the stage's session, when its engine
138    /// reported one. `None` is unknown, not free: the native loop does not
139    /// meter, and a stage done outside CAR is never known.
140    #[serde(default, skip_serializing_if = "Option::is_none")]
141    pub cost_usd: Option<f64>,
142}
143
144/// The work item record committed at `.car/multiplayer/<id>.json`.
145#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
146#[serde(deny_unknown_fields)]
147pub struct WorkItem {
148    pub schema_version: u32,
149    pub id: String,
150    /// The repository's identity across clones (same rule as `car-fleet`).
151    pub repo_root_commit: String,
152    /// Where Build started. The final squash is based here.
153    pub origin_commit: String,
154    /// Fixed at Build. The only authored text that travels between stages.
155    pub intent: String,
156    /// The locked contract, as grown by every stage so far.
157    pub contract: OutcomeContract,
158    pub stages: Vec<StageRecord>,
159}
160
161impl WorkItem {
162    pub fn next_stage(&self) -> Stage {
163        Stage::after(self.stages.len())
164    }
165
166    pub fn owners(&self) -> impl Iterator<Item = &str> {
167        self.stages.iter().map(|s| s.account_id.as_str())
168    }
169}
170
171/// The record's path inside the tree.
172pub fn record_path(id: &str) -> String {
173    format!("{RECORD_DIR}/{id}.json")
174}
175
176fn valid_item_id(id: &str) -> bool {
177    id.len() == 19
178        && id.starts_with("mp-")
179        && id[3..]
180            .bytes()
181            .all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b))
182}
183
184fn require_item_id(id: &str) -> Result<(), String> {
185    if valid_item_id(id) {
186        Ok(())
187    } else {
188        Err(format!(
189            "invalid work item id {id:?} (expected `mp-` and 16 hex digits)"
190        ))
191    }
192}
193
194/// A stable digest of a contract's checks and credential grant. The
195/// description is prose and deliberately excluded.
196pub fn contract_hash(contract: &OutcomeContract) -> String {
197    let mut checks: Vec<String> = contract
198        .checks
199        .iter()
200        .map(|c| serde_json::to_string(c).unwrap_or_default())
201        .collect();
202    checks.sort();
203    let mut hasher = Sha256::new();
204    hasher.update(if contract.allow_credentials {
205        b"1"
206    } else {
207        b"0"
208    });
209    for check in checks {
210        hasher.update([0u8]);
211        hasher.update(check.as_bytes());
212    }
213    format!("{:x}", hasher.finalize())
214}
215
216/// Whether `next` only *adds* to `prior`: every prior check present and
217/// unchanged, and no credential grant the prior contract did not make.
218/// Returns how many checks were added.
219///
220/// This is what makes independent passes safe to stack: no stage can remove or
221/// rewrite a check an earlier stage pinned. It pins the check COMMANDS, not the
222/// files they run — a stage that weakens the test script a check invokes keeps
223/// every check byte-identical, which is what the next stage's review, and the
224/// merge check's run of the final contract, are for.
225pub fn contract_grows(prior: &OutcomeContract, next: &OutcomeContract) -> Result<u64, String> {
226    unique_names(prior)?;
227    unique_names(next)?;
228    if next.allow_credentials && !prior.allow_credentials {
229        return Err(
230            "the contract grants credential access the locked contract did not; credential \
231             grants do not travel between stages"
232                .into(),
233        );
234    }
235    for check in &prior.checks {
236        match next.checks.iter().find(|c| c.name == check.name) {
237            None => {
238                return Err(format!(
239                    "the contract drops the locked check `{}`; a stage may add checks but never \
240                     remove one",
241                    check.name
242                ));
243            }
244            Some(found) if found != check => {
245                return Err(format!(
246                    "the contract changes the locked check `{}`; a stage may add checks but \
247                     never alter one",
248                    check.name
249                ));
250            }
251            Some(_) => {}
252        }
253    }
254    Ok(next.checks.len().saturating_sub(prior.checks.len()) as u64)
255}
256
257/// The contract as the record stores it: checks and credential grant as given,
258/// but the free-text `description` replaced by the item's fixed intent. The
259/// description is prose a stage can rewrite; carried along, it would be a
260/// channel for the previous owner's reasoning, which the record exists to shut.
261fn recorded(contract: &OutcomeContract, intent: &str) -> OutcomeContract {
262    OutcomeContract {
263        description: intent.to_string(),
264        ..contract.clone()
265    }
266}
267
268fn unique_names(contract: &OutcomeContract) -> Result<(), String> {
269    let mut seen = HashSet::new();
270    for check in &contract.checks {
271        if !seen.insert(check.name.as_str()) {
272            return Err(format!("the contract names check `{}` twice", check.name));
273        }
274    }
275    Ok(())
276}
277
278/// Whether a contract can carry a multiplayer work item. Beyond the coder's own
279/// validation: every stage and the merge check re-run it cold, in a fresh
280/// worktree with no session-start capture, so `baseline` and `differential`
281/// checks — which compare against a before-value captured at a session's start
282/// — would fail every such run and make the item unmergeable.
283pub fn admissible(contract: &OutcomeContract) -> Result<(), String> {
284    let issues = contract.validate();
285    if !issues.is_empty() {
286        return Err(format!("the contract is invalid: {}", issues.join("; ")));
287    }
288    unique_names(contract)?;
289    if let Some(check) = contract
290        .checks
291        .iter()
292        .find(|c| c.baseline || c.differential.is_some())
293    {
294        return Err(format!(
295            "check `{}` is a baseline/differential check; a multiplayer contract is re-run \
296             cold at every stage and at merge, where no before-value exists",
297            check.name
298        ));
299    }
300    Ok(())
301}
302
303// ---------------------------------------------------------------------------
304// git
305// ---------------------------------------------------------------------------
306
307fn git_with(
308    repo: &Path,
309    args: &[&str],
310    env: &[(&str, &Path)],
311    stdin: Option<&[u8]>,
312) -> Result<String, String> {
313    let mut cmd = Command::new("git");
314    cmd.arg("-C").arg(repo).args(args);
315    // A daemon started from a terminal must not block on a credential prompt
316    // for the remote; fail instead, and say so.
317    cmd.env("GIT_TERMINAL_PROMPT", "0");
318    for (key, value) in env {
319        cmd.env(key, value);
320    }
321    cmd.stdin(if stdin.is_some() {
322        Stdio::piped()
323    } else {
324        Stdio::null()
325    })
326    .stdout(Stdio::piped())
327    .stderr(Stdio::piped());
328    let mut child = cmd.spawn().map_err(|e| format!("git {args:?}: {e}"))?;
329    if let Some(bytes) = stdin {
330        child
331            .stdin
332            .take()
333            .ok_or("git stdin unavailable")?
334            .write_all(bytes)
335            .map_err(|e| format!("git {args:?}: {e}"))?;
336    }
337    let out = child
338        .wait_with_output()
339        .map_err(|e| format!("git {args:?}: {e}"))?;
340    if out.status.success() {
341        Ok(String::from_utf8_lossy(&out.stdout).trim_end().to_string())
342    } else {
343        Err(format!(
344            "git {} failed: {}",
345            args.join(" "),
346            String::from_utf8_lossy(&out.stderr).trim()
347        ))
348    }
349}
350
351fn git(repo: &Path, args: &[&str]) -> Result<String, String> {
352    git_with(repo, args, &[], None)
353}
354
355fn require_remote(repo: &Path, remote: &str) -> Result<(), String> {
356    if remote.is_empty() || remote.starts_with('-') {
357        return Err(format!("invalid remote name {remote:?}"));
358    }
359    git(repo, &["remote", "get-url", "--", remote])
360        .map(|_| ())
361        .map_err(|_| {
362            format!(
363                "{} has no remote named `{remote}`; a work item is shared through the team's \
364                 git remote",
365                repo.display()
366            )
367        })
368}
369
370fn remote_ref(remote: &str, id: &str) -> String {
371    format!("refs/remotes/{remote}/{BRANCH_PREFIX}{id}")
372}
373
374/// Fetch one work item's branch and return its tip.
375fn fetch_item(repo: &Path, remote: &str, id: &str) -> Result<String, String> {
376    require_item_id(id)?;
377    let spec = format!("+refs/heads/{BRANCH_PREFIX}{id}:{}", remote_ref(remote, id));
378    git(repo, &["fetch", "--quiet", "--", remote, &spec]).map_err(|e| {
379        format!("could not fetch work item {id} from `{remote}` — does it exist? ({e})")
380    })?;
381    git(
382        repo,
383        &[
384            "rev-parse",
385            "--verify",
386            &format!("{}^{{commit}}", remote_ref(remote, id)),
387        ],
388    )
389}
390
391/// Read the record as committed at `commit`.
392fn read_item(repo: &Path, commit: &str, id: &str) -> Result<WorkItem, String> {
393    let raw = git(repo, &["show", &format!("{commit}:{}", record_path(id))]).map_err(|e| {
394        if e.contains("does not exist") || e.contains("exists on disk, but not in") {
395            format!("{commit} carries no record for work item {id}")
396        } else {
397            e
398        }
399    })?;
400    let item: WorkItem = serde_json::from_str(&raw)
401        .map_err(|e| format!("work item {id} record at {commit} is not valid: {e}"))?;
402    if item.id != id {
403        return Err(format!(
404            "record at {commit} names work item {}, not {id}",
405            item.id
406        ));
407    }
408    if item.schema_version != SCHEMA_VERSION {
409        return Err(format!(
410            "work item {id} has schema version {}; this CAR reads {SCHEMA_VERSION}",
411            item.schema_version
412        ));
413    }
414    Ok(item)
415}
416
417/// Build a tree from `base`'s tree with one path set (`Some(blob)`) or removed
418/// (`None`), using a private index so the user's index is never touched.
419fn tree_with(repo: &Path, base: &str, path: &str, blob: Option<&str>) -> Result<String, String> {
420    let index = tempfile::NamedTempFile::new().map_err(|e| format!("temp index: {e}"))?;
421    let env = [("GIT_INDEX_FILE", index.path())];
422    git_with(repo, &["read-tree", base], &env, None)?;
423    match blob {
424        Some(blob) => git_with(
425            repo,
426            &[
427                "update-index",
428                "--add",
429                "--cacheinfo",
430                &format!("100644,{blob},{path}"),
431            ],
432            &env,
433            None,
434        )?,
435        None => git_with(
436            repo,
437            &["update-index", "--force-remove", "--", path],
438            &env,
439            None,
440        )?,
441    };
442    git_with(repo, &["write-tree"], &env, None)
443}
444
445fn commit_tree(repo: &Path, tree: &str, parent: &str, message: &str) -> Result<String, String> {
446    let mut args: Vec<&str> = COMMITTER.to_vec();
447    args.extend(["commit-tree", tree, "-p", parent, "-F", "-"]);
448    git_with(repo, &args, &[], Some(message.as_bytes()))
449}
450
451fn signals(repo: &Path, base: &str, result: &str, checks_added: u64) -> StageSignals {
452    let mut out = StageSignals {
453        checks_added,
454        ..StageSignals::default()
455    };
456    if let Ok(numstat) = git(repo, &["diff", "--numstat", base, result]) {
457        for line in numstat.lines() {
458            let mut cols = line.split('\t');
459            let added = cols.next().and_then(|v| v.parse::<u64>().ok());
460            let removed = cols.next().and_then(|v| v.parse::<u64>().ok());
461            out.files_changed += 1;
462            out.lines_added += added.unwrap_or(0);
463            out.lines_removed += removed.unwrap_or(0);
464        }
465    }
466    out
467}
468
469fn load_session(state_dir: &Path, session_id: &str) -> Result<CoderSession, String> {
470    if !session_id.starts_with("coder-")
471        || !session_id
472            .bytes()
473            .all(|b| b.is_ascii_alphanumeric() || b == b'-')
474    {
475        return Err(format!("invalid coder session id {session_id:?}"));
476    }
477    CoderSession::load(&state_dir.join(format!("{session_id}.json")))
478        .map_err(|_| format!("no coder session '{session_id}'"))
479}
480
481// ---------------------------------------------------------------------------
482// publish
483// ---------------------------------------------------------------------------
484
485/// What to publish.
486pub struct PublishRequest<'a> {
487    /// A finished coder session: `merged` (approved, `car/coder/<id>`
488    /// published) or `reported` (a no-change finding accepted).
489    pub session_id: &'a str,
490    /// `None` publishes a Build and creates the work item; `Some` publishes the
491    /// item's next stage.
492    pub item: Option<&'a str>,
493    pub remote: &'a str,
494    /// The stage owner. Advisory — see the module docs.
495    pub account: &'a str,
496}
497
498/// Publish a finished stage: check the hand-off rules, commit the updated
499/// record on top of the stage's work, and push `car/mp/<id>` to the remote.
500///
501/// Push is non-forcing, so two developers finishing the same stage race at
502/// the remote and the second is refused rather than overwriting the first.
503pub fn publish(state_dir: &Path, req: PublishRequest<'_>) -> Result<Value, String> {
504    let session = load_session(state_dir, req.session_id)?;
505    if session.project.is_some() {
506        return Err(
507            "a managed-project session delivers straight to its `main` and cannot be a \
508             multiplayer stage; start the stage with `repo`"
509                .into(),
510        );
511    }
512    let repo = session.repo.clone();
513    require_remote(&repo, req.remote)?;
514    let contract = session
515        .contract
516        .clone()
517        .ok_or("the session has no confirmed contract")?;
518    let base = session
519        .base
520        .clone()
521        .or_else(|| session.start_commit.clone())
522        .ok_or(
523            "the session does not record the commit it started from, so its stage cannot be \
524             placed",
525        )?;
526    // A session started from a DIRTY checkout starts at a private snapshot
527    // commit of the user's working tree — but its delivered branch commit is
528    // parented on the checkout HEAD that snapshot sits on, because shipping
529    // the user's uncommitted work is exactly what branch delivery refuses to
530    // do. So the commit this stage is placed on, and the parent to expect
531    // beneath the approved commit, is the snapshot's own parent. Comparing
532    // against the snapshot instead reported "it has moved since approval" for
533    // an ordinary `car code` in a dirty checkout, which had moved nothing.
534    let base = match session.inputs_snapshot.as_deref() {
535        Some(snapshot) => {
536            git(&repo, &["rev-parse", "--verify", &format!("{snapshot}^")]).map_err(|e| {
537                format!(
538                    "the session's inputs snapshot {snapshot} has no parent commit to place \
539                     its stage on: {e}"
540                )
541            })?
542        }
543        None => base,
544    };
545    let (result_commit, no_change) = match session.state {
546        CoderState::Merged => {
547            let branch = session
548                .result_branch
549                .as_deref()
550                .ok_or("the merged session names no result branch")?;
551            let commit = git(
552                &repo,
553                &["rev-parse", "--verify", &format!("{branch}^{{commit}}")],
554            )?;
555            // `coder.approve_merge` publishes exactly one squash commit on the
556            // session's start. The branch is a movable name, so require that
557            // shape: anything committed on it by hand afterwards is not the
558            // reviewed work and must not ride into the item.
559            let parent =
560                git(&repo, &["rev-parse", "--verify", &format!("{commit}^")]).unwrap_or_default();
561            if parent != base {
562                return Err(format!(
563                    "{branch} is not the single approved commit on the session's start \
564                     {base}; it has moved since approval, so its tip is not the reviewed work"
565                ));
566            }
567            (commit, false)
568        }
569        CoderState::Reported => (base.clone(), true),
570        other => {
571            return Err(format!(
572                "publish a stage after its coder session is approved (`merged`) or its \
573                 no-change finding is accepted (`reported`); {} is `{}`",
574                req.session_id,
575                other.as_str()
576            ));
577        }
578    };
579    let now = std::time::SystemTime::now()
580        .duration_since(std::time::UNIX_EPOCH)
581        .map(|d| d.as_secs())
582        .unwrap_or(0);
583
584    admissible(&contract)?;
585    let touched = git(
586        &repo,
587        &[
588            "diff",
589            "--name-only",
590            &base,
591            &result_commit,
592            "--",
593            RECORD_DIR,
594        ],
595    )?;
596    if !touched.is_empty() {
597        return Err(format!(
598            "the stage edited {RECORD_DIR} ({}); only the runtime writes it",
599            touched.lines().collect::<Vec<_>>().join(", ")
600        ));
601    }
602
603    let (mut item, stage, parent) = match req.item {
604        None => {
605            if no_change {
606                return Err(
607                    "Build has to build something; a no-change finding cannot start a \
608                            work item"
609                        .into(),
610                );
611            }
612            git(&repo, &["fetch", "--quiet", "--", req.remote]).map_err(|e| {
613                format!(
614                    "could not fetch `{}` to place the Build's origin ({e})",
615                    req.remote
616                )
617            })?;
618            let on_remote = git(
619                &repo,
620                &[
621                    "for-each-ref",
622                    "--contains",
623                    &base,
624                    "--format=%(refname)",
625                    &format!("refs/remotes/{}/", req.remote),
626                ],
627            )?;
628            if on_remote.trim().is_empty() {
629                return Err(format!(
630                    "the Build started at {base}, which is not on `{}`; the item's final \
631                     squash is based there, so unpushed commits under it would reach the pull \
632                     request without any stage owning them — push them first, or start the \
633                     Build from a commit that is on the remote",
634                    req.remote
635                ));
636            }
637            let root = car_fleet::worker::root_commit(&repo).map_err(|e| e.to_string())?;
638            let mut hasher = Sha256::new();
639            for part in [&root, &base, req.account, req.session_id] {
640                hasher.update(part.as_bytes());
641                hasher.update([0u8]);
642            }
643            let id = format!("mp-{}", &format!("{:x}", hasher.finalize())[..16]);
644            let existing = git(
645                &repo,
646                &[
647                    "ls-remote",
648                    "--heads",
649                    "--",
650                    req.remote,
651                    &format!("refs/heads/{BRANCH_PREFIX}{id}"),
652                ],
653            )?;
654            if !existing.is_empty() {
655                return Err(format!("work item {id} already exists on `{}`", req.remote));
656            }
657            let item = WorkItem {
658                schema_version: SCHEMA_VERSION,
659                id,
660                repo_root_commit: root,
661                origin_commit: base.clone(),
662                intent: session.intent.clone(),
663                contract: recorded(&contract, &session.intent),
664                stages: Vec::new(),
665            };
666            (item, Stage::Build, result_commit.clone())
667        }
668        Some(id) => {
669            let tip = fetch_item(&repo, req.remote, id)?;
670            let item = read_item(&repo, &tip, id)?;
671            if item.owners().any(|owner| owner == req.account) {
672                return Err(format!(
673                    "account {} already owns a stage of {id}; each stage must be a different \
674                     developer (advisory until stage receipts are attested)",
675                    req.account
676                ));
677            }
678            if base != tip {
679                return Err(format!(
680                    "a {} stage must start from the work item's tip {tip}; session {} started \
681                     at {base} — start it with `coder.start {{ base: \"{tip}\" }}` or \
682                     `multiplayer.start_stage`",
683                    item.next_stage().as_str(),
684                    req.session_id
685                ));
686            }
687            let stage = item.next_stage();
688            let parent = if no_change {
689                tip.clone()
690            } else {
691                result_commit.clone()
692            };
693            (item, stage, parent)
694        }
695    };
696
697    let checks_added = contract_grows(&item.contract, &contract)?;
698    item.contract = recorded(&contract, &item.intent);
699    item.stages.push(StageRecord {
700        stage,
701        account_id: req.account.to_string(),
702        session_id: Some(req.session_id.to_string()),
703        engine: session.engine.label(),
704        model: session.model.clone(),
705        base_commit: base.clone(),
706        result_commit: result_commit.clone(),
707        no_change,
708        contract_hash: contract_hash(&contract),
709        finished_at: now,
710        signals: signals(&repo, &base, &result_commit, checks_added),
711        cost_usd: session.cost_usd,
712    });
713
714    commit_and_push(&repo, req.remote, &item, stage, &parent)
715}
716
717/// Commit `item` (already carrying the new stage) on top of `parent` and push
718/// `car/mp/<id>` without force.
719fn commit_and_push(
720    repo: &Path,
721    remote: &str,
722    item: &WorkItem,
723    stage: Stage,
724    parent: &str,
725) -> Result<Value, String> {
726    let mut body = serde_json::to_vec_pretty(item).map_err(|e| e.to_string())?;
727    body.push(b'\n');
728    let blob = git_with(repo, &["hash-object", "-w", "--stdin"], &[], Some(&body))?;
729    let path = record_path(&item.id);
730    let tree = tree_with(repo, parent, &path, Some(&blob))?;
731    let message = format!(
732        "multiplayer: {} of {}\n\nMultiplayer-Item: {}\nMultiplayer-Stage: {}\n",
733        stage.as_str(),
734        item.id,
735        item.id,
736        stage.as_str()
737    );
738    let commit = commit_tree(repo, &tree, parent, &message)?;
739    let branch = format!("{BRANCH_PREFIX}{}", item.id);
740    git(
741        repo,
742        &[
743            "push",
744            "--quiet",
745            "--",
746            remote,
747            &format!("{commit}:refs/heads/{branch}"),
748        ],
749    )
750    .map_err(|e| {
751        format!(
752            "could not push {branch} to `{remote}` — if another developer published this \
753             stage first, the work item has moved on ({e})"
754        )
755    })?;
756    // The push is what counts; the local branch is a convenience. Never move it
757    // under a worktree that has it checked out, and never turn a successful
758    // publish into an error because the convenience failed.
759    let local_branch_updated = update_local_branch(repo, &branch, &commit);
760
761    Ok(json!({
762        "item_id": item.id,
763        "local_branch_updated": local_branch_updated,
764        "stage": stage.as_str(),
765        "commit": commit,
766        "branch": branch,
767        "remote": remote,
768        "next_stage": item.next_stage().as_str(),
769        "owners": item.owners().collect::<Vec<_>>(),
770    }))
771}
772
773/// Point `refs/heads/<branch>` at `commit` unless some worktree has that
774/// branch checked out. Returns whether it moved.
775fn update_local_branch(repo: &Path, branch: &str, commit: &str) -> bool {
776    let full = format!("refs/heads/{branch}");
777    let checked_out = git(repo, &["worktree", "list", "--porcelain"])
778        .map(|list| list.lines().any(|l| l == format!("branch {full}")))
779        .unwrap_or(true);
780    !checked_out && git(repo, &["update-ref", &full, commit]).is_ok()
781}
782
783/// A worktree label no concurrent call shares: provisioning self-heals by
784/// force-removing whatever sits at its path, so two calls on one label would
785/// delete each other's tree mid-run.
786fn unique_label(base: &str) -> String {
787    use std::sync::atomic::{AtomicU64, Ordering};
788    static N: AtomicU64 = AtomicU64::new(0);
789    format!(
790        "{base}-{}-{}",
791        std::process::id(),
792        N.fetch_add(1, Ordering::Relaxed)
793    )
794}
795
796/// Run `contract` in a fresh worktree of `rev`. The checks were written by
797/// other developers and execute on this machine — as merging or testing their
798/// branch already would — so they run the way the model's own shell does:
799/// without the forge credential (no `GH_*` tokens, neutralized git/gh/ssh
800/// helpers), and under the inspector chain that refuses credential-shaped
801/// commands whatever the contract's `allow_credentials` says. That is
802/// hardening, not a sandbox: the rest of the environment is inherited.
803async fn run_contract_at(
804    repo: &Path,
805    rev: &str,
806    worktree_base: &Path,
807    label: &str,
808    contract: &OutcomeContract,
809) -> Result<Vec<CheckResult>, String> {
810    let (repo, base, rev, label) = (
811        repo.to_path_buf(),
812        worktree_base.to_path_buf(),
813        rev.to_string(),
814        unique_label(label),
815    );
816    let sink_label = label.clone();
817    let (workspace, executor) = tokio::task::spawn_blocking(move || {
818        let config = car_multi::WorkspaceConfig::git_worktree_at(&repo, &base).with_rev(rev);
819        let workspace = car_multi::AgentWorkspace::provision(&config, &label)?;
820        let executor = super::shell_tool::WorktreeExecutor::for_coder_session(workspace.path())?
821            .withholding_forge_credentials();
822        Ok::<_, String>((workspace, executor))
823    })
824    .await
825    .map_err(|e| e.to_string())??;
826    let mut contract = contract.clone();
827    contract.allow_credentials = false;
828    let sink = EventSink::new(sink_label, None, None);
829    let results = super::contract::evaluate_contract(&contract, &executor, &sink).await;
830    drop(executor);
831    // Removing the worktree is blocking git too.
832    let _ = tokio::task::spawn_blocking(move || drop(workspace)).await;
833    Ok(results)
834}
835
836fn all_green(results: &[CheckResult]) -> bool {
837    !results.is_empty() && results.iter().all(|r| r.passed)
838}
839
840/// A stage done outside CAR — a developer's own Claude Code or Codex session.
841pub struct SubmitRequest<'a> {
842    /// A checkout that has, or can fetch, `commit`.
843    pub repo: &'a Path,
844    pub item: &'a str,
845    /// The stage's work: a commit descending from the item's tip.
846    pub commit: &'a str,
847    pub remote: &'a str,
848    pub account: &'a str,
849    /// Checks to add to the locked contract (never replace one).
850    pub contract_additions: Vec<super::contract::ContractCheck>,
851}
852
853/// Submit a stage done outside CAR. CAR did not watch those edits, so it
854/// judges only what it can verify itself: the commit descends from the tip,
855/// changes something (an empty submission is refused — "no change" cannot be
856/// adjudicated for work CAR did not see), leaves the record alone, and passes
857/// the locked contract plus any additions, run here without credentials.
858pub async fn submit_stage(req: SubmitRequest<'_>, worktree_base: &Path) -> Result<Value, String> {
859    let (repo, remote, id, commit, account) = (
860        req.repo.to_path_buf(),
861        req.remote.to_string(),
862        req.item.to_string(),
863        req.commit.to_string(),
864        req.account.to_string(),
865    );
866    let (tip, item, commit) = {
867        let (repo, remote, id, account) =
868            (repo.clone(), remote.clone(), id.clone(), account.clone());
869        tokio::task::spawn_blocking(move || -> Result<(String, WorkItem, String), String> {
870            let (tip, item) = prepare_stage(&repo, &remote, &id, &account)?;
871            if commit.starts_with('-') {
872                return Err(format!("invalid commit {commit:?}"));
873            }
874            let commit = git(
875                &repo,
876                &["rev-parse", "--verify", &format!("{commit}^{{commit}}")],
877            )
878            .map_err(|_| format!("{commit} does not name a commit in {}", repo.display()))?;
879            git(&repo, &["merge-base", "--is-ancestor", &tip, &commit])
880                .map_err(|_| format!("{commit} does not descend from the work item's tip {tip}"))?;
881            if commit == tip {
882                return Err(
883                    "the submitted commit is the tip itself: a stage done outside CAR must \
884                     change something, because CAR did not watch the work and cannot judge \
885                     a \"no change\" conclusion"
886                        .into(),
887                );
888            }
889            let touched = git(
890                &repo,
891                &["diff", "--name-only", &tip, &commit, "--", RECORD_DIR],
892            )?;
893            if !touched.is_empty() {
894                return Err(format!(
895                    "the stage edited the work item record ({}); only the runtime writes it",
896                    touched.lines().collect::<Vec<_>>().join(", ")
897                ));
898            }
899            Ok((tip, item, commit))
900        })
901        .await
902        .map_err(|e| e.to_string())??
903    };
904
905    let mut contract = item.contract.clone();
906    for check in req.contract_additions {
907        if contract.checks.iter().any(|c| c.name == check.name) {
908            return Err(format!(
909                "`contract_additions` may only add checks; `{}` is already in the locked \
910                 contract",
911                check.name
912            ));
913        }
914        contract.checks.push(check);
915    }
916    admissible(&contract)?;
917    let results = run_contract_at(
918        &repo,
919        &commit,
920        worktree_base,
921        &format!("{id}-submit"),
922        &contract,
923    )
924    .await?;
925    if !all_green(&results) {
926        let red: Vec<&str> = results
927            .iter()
928            .filter(|r| !r.passed)
929            .map(|r| r.name.as_str())
930            .collect();
931        return Err(format!(
932            "the contract is not green at {commit} (failing: {}); fix the work and submit \
933             again",
934            if red.is_empty() {
935                "no checks ran".to_string()
936            } else {
937                red.join(", ")
938            }
939        ));
940    }
941
942    tokio::task::spawn_blocking(move || {
943        let mut item = item;
944        let checks_added = contract_grows(&item.contract, &contract)?;
945        let stage = item.next_stage();
946        item.contract = recorded(&contract, &item.intent);
947        item.stages.push(StageRecord {
948            stage,
949            account_id: account,
950            session_id: None,
951            engine: "external-unmanaged".into(),
952            model: None,
953            base_commit: tip.clone(),
954            result_commit: commit.clone(),
955            no_change: false,
956            contract_hash: contract_hash(&contract),
957            finished_at: std::time::SystemTime::now()
958                .duration_since(std::time::UNIX_EPOCH)
959                .map(|d| d.as_secs())
960                .unwrap_or(0),
961            signals: signals(&repo, &tip, &commit, checks_added),
962            cost_usd: None,
963        });
964        let mut out = commit_and_push(&repo, &remote, &item, stage, &commit)?;
965        out["checks"] = json!(results);
966        Ok(out)
967    })
968    .await
969    .map_err(|e| e.to_string())?
970}
971
972// ---------------------------------------------------------------------------
973// read side
974// ---------------------------------------------------------------------------
975
976/// The tip and record of one item, refusing a caller who already owns a stage.
977pub fn prepare_stage(
978    repo: &Path,
979    remote: &str,
980    id: &str,
981    account: &str,
982) -> Result<(String, WorkItem), String> {
983    require_remote(repo, remote)?;
984    let tip = fetch_item(repo, remote, id)?;
985    let item = read_item(repo, &tip, id)?;
986    if item.owners().any(|owner| owner == account) {
987        return Err(format!(
988            "account {account} already owns a stage of {id}; each stage must be a different \
989             developer (advisory until stage receipts are attested)"
990        ));
991    }
992    Ok((tip, item))
993}
994
995fn summary_row(item: &WorkItem, tip: &str, account: Option<&str>) -> Value {
996    let distinct: HashSet<&str> = item.owners().collect();
997    json!({
998        "item_id": item.id,
999        "intent": item.intent,
1000        "tip": tip,
1001        "stages": item.stages.iter().map(|s| json!({
1002            "stage": s.stage.as_str(),
1003            "account_id": s.account_id,
1004            "engine": s.engine,
1005            "no_change": s.no_change,
1006            "signals": s.signals,
1007            "cost_usd": s.cost_usd,
1008        })).collect::<Vec<_>>(),
1009        "next_stage": item.next_stage().as_str(),
1010        "eligible": account.map(|a| !item.owners().any(|owner| owner == a)),
1011        // Stage count and distinct owners only; `merge_check` is the verdict.
1012        "ready_to_merge": item.stages.len() >= 3 && distinct.len() == item.stages.len(),
1013    })
1014}
1015
1016/// Every work item on `remote`, with the caller's eligibility for its next
1017/// stage when `account` is known.
1018pub fn list(repo: &Path, remote: &str, account: Option<&str>) -> Result<Value, String> {
1019    require_remote(repo, remote)?;
1020    let heads = git(
1021        repo,
1022        &[
1023            "ls-remote",
1024            "--heads",
1025            "--",
1026            remote,
1027            &format!("refs/heads/{BRANCH_PREFIX}*"),
1028        ],
1029    )?;
1030    let ids: Vec<String> = heads
1031        .lines()
1032        .filter_map(|line| line.split('\t').nth(1))
1033        .filter_map(|r| r.strip_prefix(&format!("refs/heads/{BRANCH_PREFIX}")))
1034        .filter(|id| valid_item_id(id))
1035        .map(str::to_string)
1036        .collect();
1037    let mut rows = Vec::new();
1038    let mut unreadable = Vec::new();
1039    for id in &ids {
1040        match fetch_item(repo, remote, id).and_then(|tip| {
1041            let item = read_item(repo, &tip, id)?;
1042            Ok(summary_row(&item, &tip, account))
1043        }) {
1044            Ok(row) => rows.push(row),
1045            Err(e) => unreadable.push(json!({ "item_id": id, "error": e })),
1046        }
1047    }
1048    Ok(json!({ "items": rows, "unreadable": unreadable }))
1049}
1050
1051/// One work item's full record and tip.
1052pub fn get(repo: &Path, remote: &str, id: &str) -> Result<Value, String> {
1053    require_remote(repo, remote)?;
1054    let tip = fetch_item(repo, remote, id)?;
1055    let item = read_item(repo, &tip, id)?;
1056    Ok(json!({ "tip": tip, "item": item }))
1057}
1058
1059// ---------------------------------------------------------------------------
1060// merge check
1061// ---------------------------------------------------------------------------
1062
1063/// The history problems that make an item unmergeable, independent of the
1064/// contract run. Empty = the recorded history holds.
1065fn history_problems(repo: &Path, tip: &str, item: &WorkItem) -> Result<Vec<String>, String> {
1066    let mut problems = Vec::new();
1067    let required = [Stage::Build, Stage::Improve, Stage::Polish];
1068    if item.stages.len() < required.len() {
1069        problems.push(format!(
1070            "only {} of the required stages (build, improve, polish) are recorded",
1071            item.stages.len()
1072        ));
1073    }
1074    for (i, record) in item.stages.iter().enumerate() {
1075        if record.stage != Stage::after(i) {
1076            problems.push(format!(
1077                "stage {} is recorded as {}, expected {}",
1078                i + 1,
1079                record.stage.as_str(),
1080                Stage::after(i).as_str()
1081            ));
1082        }
1083    }
1084    let mut seen = HashSet::new();
1085    for owner in item.owners() {
1086        if !seen.insert(owner) {
1087            problems.push(format!("account {owner} owns more than one stage"));
1088        }
1089    }
1090
1091    // The record must be tied to the code, not just to itself:
1092    //
1093    // - every commit that touched the record is a record commit that changed
1094    //   ONLY the record (a trailer is trivially forgeable; the diff is not);
1095    // - each version appends exactly one stage and only grows the contract;
1096    // - record commit i sits directly on stage i's result, and stage i started
1097    //   from record commit i-1 (Build: from the origin) — so every code commit
1098    //   on the branch is some stage's reviewed work;
1099    // - the tip IS the last record commit, so nothing was pushed after Polish.
1100    let path = record_path(&item.id);
1101    let log = git(
1102        repo,
1103        &["log", "--first-parent", "--format=%H", tip, "--", &path],
1104    )?;
1105    let mut commits: Vec<&str> = log.lines().collect();
1106    commits.reverse();
1107    if commits.last().copied() != Some(tip) {
1108        problems.push(format!(
1109            "the tip {tip} is not a record commit: something was pushed to the branch after \
1110             the last stage was published"
1111        ));
1112    }
1113    let mut previous: Option<(String, WorkItem)> = None;
1114    for (i, commit) in commits.iter().enumerate() {
1115        let message = git(repo, &["log", "-1", "--format=%B", commit])?;
1116        if !message
1117            .lines()
1118            .any(|l| l.trim() == format!("Multiplayer-Item: {}", item.id))
1119        {
1120            problems.push(format!(
1121                "{commit} changed the record but is not a record commit; only the runtime \
1122                 writes it"
1123            ));
1124            continue;
1125        }
1126        let changed = git(
1127            repo,
1128            &["diff", "--name-only", &format!("{commit}^"), commit],
1129        )?;
1130        if changed.lines().collect::<Vec<_>>() != [path.as_str()] {
1131            problems.push(format!(
1132                "record commit {commit} changes more than the record ({})",
1133                changed.lines().collect::<Vec<_>>().join(", ")
1134            ));
1135        }
1136        let version = match read_item(repo, commit, &item.id) {
1137            Ok(version) => version,
1138            Err(e) => {
1139                problems.push(e);
1140                continue;
1141            }
1142        };
1143        let Some(stage) = version.stages.last() else {
1144            problems.push(format!("record commit {commit} records no stage"));
1145            continue;
1146        };
1147        if version.stages.len() != i + 1 {
1148            problems.push(format!(
1149                "record commit {commit} is the #{} record commit but lists {} stage(s)",
1150                i + 1,
1151                version.stages.len()
1152            ));
1153        }
1154        let parent = git(repo, &["rev-parse", &format!("{commit}^")])?;
1155        if parent != stage.result_commit {
1156            problems.push(format!(
1157                "record commit {commit} does not sit on its stage's result {}",
1158                stage.result_commit
1159            ));
1160        }
1161        let expected_base = match &previous {
1162            None => version.origin_commit.clone(),
1163            Some((prev_commit, _)) => prev_commit.clone(),
1164        };
1165        if stage.base_commit != expected_base {
1166            problems.push(format!(
1167                "stage {} records base {} but should start from {expected_base}",
1168                i + 1,
1169                stage.base_commit
1170            ));
1171        }
1172        if git(
1173            repo,
1174            &[
1175                "merge-base",
1176                "--is-ancestor",
1177                &stage.base_commit,
1178                &stage.result_commit,
1179            ],
1180        )
1181        .is_err()
1182        {
1183            problems.push(format!(
1184                "stage {}'s result does not descend from its base",
1185                i + 1
1186            ));
1187        }
1188        if let Some((_, prev)) = &previous {
1189            if version.stages[..prev.stages.len().min(version.stages.len())]
1190                != prev.stages[..prev.stages.len().min(version.stages.len())]
1191                || version.origin_commit != prev.origin_commit
1192            {
1193                problems.push(format!("{commit} rewrites earlier stages of the record"));
1194            }
1195            if let Err(e) = contract_grows(&prev.contract, &version.contract) {
1196                problems.push(format!("{commit}: {e}"));
1197            }
1198        }
1199        previous = Some((commit.to_string(), version));
1200    }
1201    if commits.len() != item.stages.len() {
1202        problems.push(format!(
1203            "the record was written {} time(s) for {} stage(s)",
1204            commits.len(),
1205            item.stages.len()
1206        ));
1207    }
1208    if let Err(e) = admissible(&item.contract) {
1209        problems.push(e);
1210    }
1211    Ok(problems)
1212}
1213
1214/// Re-verify a work item end to end and, if it holds, produce
1215/// `car/mp/<id>-final`: one squash commit on the item's origin, with the
1216/// record removed, for the team's normal pull-request flow.
1217///
1218/// Runs the item's final contract in a fresh worktree of the tip, with
1219/// credential access removed whatever the contract says. That executes the
1220/// repository's checks — written by other developers — on this machine, which
1221/// is what merging a branch already means.
1222pub async fn merge_check(
1223    repo: &Path,
1224    remote: &str,
1225    id: &str,
1226    worktree_base: &Path,
1227) -> Result<Value, String> {
1228    let (tip, item) = {
1229        let repo = repo.to_path_buf();
1230        let (remote, id) = (remote.to_string(), id.to_string());
1231        tokio::task::spawn_blocking(move || -> Result<(String, WorkItem), String> {
1232            require_remote(&repo, &remote)?;
1233            let tip = fetch_item(&repo, &remote, &id)?;
1234            let item = read_item(&repo, &tip, &id)?;
1235            Ok((tip, item))
1236        })
1237        .await
1238        .map_err(|e| e.to_string())??
1239    };
1240    let mut problems = {
1241        let (repo, tip, item) = (repo.to_path_buf(), tip.clone(), item.clone());
1242        tokio::task::spawn_blocking(move || history_problems(&repo, &tip, &item))
1243            .await
1244            .map_err(|e| e.to_string())??
1245    };
1246
1247    // Build the squash that would merge — the tip's tree, minus the record, on
1248    // the item's origin — as a bare commit object first. The contract is then
1249    // run on THAT tree, not on the tip, so what gets verified is what merges.
1250    // A branch whose history is already known to be wrong gets neither: its
1251    // commands are not executed on this machine.
1252    let squash = if problems.is_empty() {
1253        let (repo, tip, id, item) = (
1254            repo.to_path_buf(),
1255            tip.clone(),
1256            id.to_string(),
1257            item.clone(),
1258        );
1259        Some(
1260            tokio::task::spawn_blocking(move || -> Result<String, String> {
1261                let tree = tree_with(&repo, &tip, &record_path(&id), None)?;
1262                let mut message = format!(
1263                    "{}\n\nMultiplayer-Item: {id}\n",
1264                    item.intent
1265                        .lines()
1266                        .next()
1267                        .unwrap_or("multiplayer work item")
1268                );
1269                for record in &item.stages {
1270                    message.push_str(&format!(
1271                        "Multiplayer-{}: {}\n",
1272                        capitalize(record.stage.as_str()),
1273                        record.account_id
1274                    ));
1275                }
1276                commit_tree(&repo, &tree, &item.origin_commit, &message)
1277            })
1278            .await
1279            .map_err(|e| e.to_string())??,
1280        )
1281    } else {
1282        None
1283    };
1284    let results = match &squash {
1285        Some(commit) => {
1286            run_contract_at(
1287                repo,
1288                commit,
1289                worktree_base,
1290                &format!("{id}-check"),
1291                &item.contract,
1292            )
1293            .await?
1294        }
1295        None => Vec::new(),
1296    };
1297    if squash.is_some() && !all_green(&results) {
1298        problems.push("the final contract is not green on the squash that would merge".to_string());
1299    }
1300
1301    let (final_branch, final_commit) = match squash.filter(|_| problems.is_empty()) {
1302        Some(commit) => {
1303            let (repo, id) = (repo.to_path_buf(), id.to_string());
1304            tokio::task::spawn_blocking(
1305                move || -> Result<(Option<String>, Option<String>), String> {
1306                    let branch = format!("{BRANCH_PREFIX}{id}-final");
1307                    if !update_local_branch(&repo, &branch, &commit) {
1308                        return Err(format!(
1309                            "{branch} is checked out in a worktree; switch away from it and run \
1310                         the merge check again"
1311                        ));
1312                    }
1313                    Ok((Some(branch), Some(commit)))
1314                },
1315            )
1316            .await
1317            .map_err(|e| e.to_string())??
1318        }
1319        None => (None, None),
1320    };
1321
1322    Ok(json!({
1323        "item_id": id,
1324        "tip": tip,
1325        "mergeable": problems.is_empty(),
1326        "problems": problems,
1327        "checks": results,
1328        "final_branch": final_branch,
1329        "final_commit": final_commit,
1330        "advisory": "stage ownership is self-reported until stage receipts are attested",
1331    }))
1332}
1333
1334fn capitalize(s: &str) -> String {
1335    let mut chars = s.chars();
1336    match chars.next() {
1337        Some(first) => first.to_ascii_uppercase().to_string() + chars.as_str(),
1338        None => String::new(),
1339    }
1340}
1341
1342// ---------------------------------------------------------------------------
1343// JSON-RPC handlers
1344// ---------------------------------------------------------------------------
1345
1346/// Multiplayer calls push to a shared remote, run other developers' checks,
1347/// and attribute stages to the signed-in person. None of that is an agent's
1348/// to do on an operator's behalf.
1349async fn refuse_agent(session: &ClientSession, method: &str) -> Result<(), String> {
1350    if let Some(agent) = session.agent_id.lock().await.clone() {
1351        if !session.is_host.load(std::sync::atomic::Ordering::Acquire) {
1352            return Err(format!(
1353                "`{method}` is operator-only: `{agent}` cannot act as a developer in a \
1354                 multiplayer work item"
1355            ));
1356        }
1357    }
1358    Ok(())
1359}
1360
1361async fn current_account() -> Result<String, String> {
1362    car_auth::local_auth_snapshot()
1363        .await?
1364        .active_account_id
1365        .ok_or_else(|| {
1366            "multiplayer stages are attributed to your Parslee account; sign in first \
1367             (`car auth login`)"
1368                .to_string()
1369        })
1370}
1371
1372fn remote_param(params: &Value) -> String {
1373    params
1374        .get("remote")
1375        .and_then(Value::as_str)
1376        .filter(|r| !r.trim().is_empty())
1377        .unwrap_or("origin")
1378        .to_string()
1379}
1380
1381#[derive(Deserialize)]
1382struct PublishParams {
1383    session_id: String,
1384    #[serde(default)]
1385    item: Option<String>,
1386}
1387
1388pub async fn handle_publish(
1389    req: &JsonRpcMessage,
1390    session: &ClientSession,
1391) -> Result<Value, String> {
1392    refuse_agent(session, "multiplayer.publish").await?;
1393    let params: PublishParams =
1394        serde_json::from_value(req.params.clone()).map_err(|e| format!("invalid params: {e}"))?;
1395    let remote = remote_param(&req.params);
1396    let account = current_account().await?;
1397    let state_dir = super::rpc::coder_state_dir()?;
1398    tokio::task::spawn_blocking(move || {
1399        publish(
1400            &state_dir,
1401            PublishRequest {
1402                session_id: &params.session_id,
1403                item: params.item.as_deref(),
1404                remote: &remote,
1405                account: &account,
1406            },
1407        )
1408    })
1409    .await
1410    .map_err(|e| e.to_string())?
1411}
1412
1413#[derive(Deserialize)]
1414struct SubmitParams {
1415    repo: PathBuf,
1416    item: String,
1417    commit: String,
1418    #[serde(default)]
1419    contract_additions: Vec<super::contract::ContractCheck>,
1420}
1421
1422pub async fn handle_submit_stage(
1423    req: &JsonRpcMessage,
1424    session: &ClientSession,
1425) -> Result<Value, String> {
1426    refuse_agent(session, "multiplayer.submit_stage").await?;
1427    let params: SubmitParams =
1428        serde_json::from_value(req.params.clone()).map_err(|e| format!("invalid params: {e}"))?;
1429    let remote = remote_param(&req.params);
1430    let account = current_account().await?;
1431    let worktrees = super::rpc::coder_state_dir()?.join("multiplayer-worktrees");
1432    submit_stage(
1433        SubmitRequest {
1434            repo: &params.repo,
1435            item: &params.item,
1436            commit: &params.commit,
1437            remote: &remote,
1438            account: &account,
1439            contract_additions: params.contract_additions,
1440        },
1441        &worktrees,
1442    )
1443    .await
1444}
1445
1446#[derive(Deserialize)]
1447struct StartStageParams {
1448    repo: PathBuf,
1449    item: String,
1450    #[serde(default)]
1451    engine: Option<String>,
1452    #[serde(default)]
1453    model: Option<String>,
1454}
1455
1456/// Start the item's next stage as an ordinary coder session at the item's tip.
1457/// The reply is `coder.start`'s, plus `multiplayer.locked_contract`: confirm
1458/// with `coder.confirm_contract { contract: <that> }` (adding checks is fine;
1459/// `multiplayer.publish` refuses a contract that dropped or changed one).
1460pub async fn handle_start_stage(
1461    req: &JsonRpcMessage,
1462    state: &Arc<ServerState>,
1463    session: &Arc<ClientSession>,
1464) -> Result<Value, String> {
1465    refuse_agent(session, "multiplayer.start_stage").await?;
1466    let params: StartStageParams =
1467        serde_json::from_value(req.params.clone()).map_err(|e| format!("invalid params: {e}"))?;
1468    let remote = remote_param(&req.params);
1469    let account = current_account().await?;
1470    let (tip, item) = {
1471        let (repo, id) = (params.repo.clone(), params.item.clone());
1472        tokio::task::spawn_blocking(move || prepare_stage(&repo, &remote, &id, &account))
1473            .await
1474            .map_err(|e| e.to_string())??
1475    };
1476    let mut start = json!({
1477        "repo": params.repo,
1478        "intent": item.intent,
1479        "base": tip,
1480    });
1481    if let Some(engine) = &params.engine {
1482        start["engine"] = json!(engine);
1483    }
1484    if let Some(model) = &params.model {
1485        start["model"] = json!(model);
1486    }
1487    let start_req = JsonRpcMessage {
1488        params: start,
1489        ..req.clone()
1490    };
1491    let mut response = super::rpc::handle_coder_start(&start_req, state, session).await?;
1492    // Credential grants do not travel: the next owner confirms the locked
1493    // checks WITHOUT the previous owner's `allow_credentials`, and grants it
1494    // again themselves only if they choose to (publish accepts either).
1495    let mut locked = item.contract.clone();
1496    locked.allow_credentials = false;
1497    response["multiplayer"] = json!({
1498        "item_id": item.id,
1499        "stage": item.next_stage().as_str(),
1500        "locked_contract": locked,
1501        "owners": item.owners().collect::<Vec<_>>(),
1502    });
1503    Ok(response)
1504}
1505
1506#[derive(Deserialize)]
1507struct RepoParams {
1508    repo: PathBuf,
1509    #[serde(default)]
1510    item: Option<String>,
1511}
1512
1513pub async fn handle_list(req: &JsonRpcMessage, session: &ClientSession) -> Result<Value, String> {
1514    // Reading fetches into the repository the caller names, which runs that
1515    // repository's own git config and hooks: not an agent's to point at.
1516    refuse_agent(session, "multiplayer.list").await?;
1517    let params: RepoParams =
1518        serde_json::from_value(req.params.clone()).map_err(|e| format!("invalid params: {e}"))?;
1519    let remote = remote_param(&req.params);
1520    // Signed out is fine for reading; eligibility is then unknown (`null`).
1521    let account = current_account().await.ok();
1522    tokio::task::spawn_blocking(move || list(&params.repo, &remote, account.as_deref()))
1523        .await
1524        .map_err(|e| e.to_string())?
1525}
1526
1527pub async fn handle_get(req: &JsonRpcMessage, session: &ClientSession) -> Result<Value, String> {
1528    refuse_agent(session, "multiplayer.get").await?;
1529    let params: RepoParams =
1530        serde_json::from_value(req.params.clone()).map_err(|e| format!("invalid params: {e}"))?;
1531    let id = params.item.ok_or("`item` is required")?;
1532    let remote = remote_param(&req.params);
1533    tokio::task::spawn_blocking(move || get(&params.repo, &remote, &id))
1534        .await
1535        .map_err(|e| e.to_string())?
1536}
1537
1538pub async fn handle_merge_check(
1539    req: &JsonRpcMessage,
1540    session: &ClientSession,
1541) -> Result<Value, String> {
1542    refuse_agent(session, "multiplayer.merge_check").await?;
1543    let params: RepoParams =
1544        serde_json::from_value(req.params.clone()).map_err(|e| format!("invalid params: {e}"))?;
1545    let id = params.item.ok_or("`item` is required")?;
1546    let remote = remote_param(&req.params);
1547    let worktrees = super::rpc::coder_state_dir()?.join("multiplayer-worktrees");
1548    merge_check(&params.repo, &remote, &id, &worktrees).await
1549}
1550
1551#[cfg(test)]
1552mod tests {
1553    use super::*;
1554    use crate::coder::contract::ContractCheck;
1555    use crate::coder::router::EngineChoice;
1556    use crate::coder::test_cmds;
1557
1558    fn run(dir: &Path, args: &[&str]) -> String {
1559        let out = Command::new("git")
1560            .env("GIT_CONFIG_NOSYSTEM", "1")
1561            .env("GIT_CONFIG_GLOBAL", "/dev/null")
1562            .arg("-C")
1563            .arg(dir)
1564            .args([
1565                "-c",
1566                "user.name=t",
1567                "-c",
1568                "user.email=t@t",
1569                "-c",
1570                "commit.gpgSign=false",
1571            ])
1572            .args(args)
1573            .output()
1574            .unwrap();
1575        assert!(
1576            out.status.success(),
1577            "git {args:?}: {}",
1578            String::from_utf8_lossy(&out.stderr)
1579        );
1580        String::from_utf8(out.stdout).unwrap().trim().to_string()
1581    }
1582
1583    fn check(name: &str, file: &str) -> ContractCheck {
1584        ContractCheck {
1585            name: name.into(),
1586            command: test_cmds::file_exists(file),
1587            expect_exit_zero: true,
1588            output_contains: None,
1589            timeout_secs: 30,
1590            baseline: false,
1591            differential: None,
1592        }
1593    }
1594
1595    fn contract(checks: Vec<ContractCheck>) -> OutcomeContract {
1596        OutcomeContract {
1597            allow_credentials: false,
1598            description: "files exist".into(),
1599            checks,
1600        }
1601    }
1602
1603    /// A commit of `file` on a fresh `car/coder/<tag>` branch at `at`, the way
1604    /// `coder.approve_merge` publishes one.
1605    fn coder_branch(repo: &Path, tag: &str, at: &str, file: &str) -> String {
1606        let branch = format!("car/coder/{tag}");
1607        run(repo, &["checkout", "-q", "-b", &branch, at]);
1608        std::fs::write(repo.join(file), tag).unwrap();
1609        run(repo, &["add", file]);
1610        run(repo, &["commit", "-q", "-m", tag]);
1611        run(repo, &["checkout", "-q", "--detach"]);
1612        branch
1613    }
1614
1615    /// A finished stage session, persisted where `publish` reads it.
1616    fn stage_session(
1617        state_dir: &Path,
1618        repo: &Path,
1619        state: CoderState,
1620        branch: Option<&str>,
1621        base: Option<&str>,
1622        start: Option<&str>,
1623        contract: OutcomeContract,
1624    ) -> String {
1625        let mut s = CoderSession::new(
1626            repo,
1627            "make a.txt and b.txt exist",
1628            EngineChoice::Native,
1629            1,
1630            Some(state_dir.to_path_buf()),
1631        );
1632        s.state = state;
1633        s.result_branch = branch.map(str::to_string);
1634        s.base = base.map(str::to_string);
1635        s.start_commit = start.map(str::to_string);
1636        s.contract = Some(contract);
1637        s.persist().unwrap();
1638        s.id
1639    }
1640
1641    struct Team {
1642        _root: tempfile::TempDir,
1643        state_dir: tempfile::TempDir,
1644        alice: PathBuf,
1645        bob: PathBuf,
1646        carol: PathBuf,
1647        origin: String,
1648    }
1649
1650    /// A bare remote and three developers' clones of it.
1651    fn team() -> Team {
1652        let root = tempfile::tempdir().unwrap();
1653        let remote = root.path().join("remote.git");
1654        std::fs::create_dir_all(&remote).unwrap();
1655        run(&remote, &["init", "-q", "--bare", "-b", "main"]);
1656        let clone = |name: &str| {
1657            let dir = root.path().join(name);
1658            run(
1659                root.path(),
1660                &["clone", "-q", remote.to_str().unwrap(), name],
1661            );
1662            dir
1663        };
1664        let alice = clone("alice");
1665        std::fs::write(alice.join("README"), "hi").unwrap();
1666        run(&alice, &["add", "README"]);
1667        run(&alice, &["commit", "-q", "-m", "init"]);
1668        run(&alice, &["push", "-q", "origin", "HEAD:main"]);
1669        let origin = run(&alice, &["rev-parse", "HEAD"]);
1670        let bob = clone("bob");
1671        let carol = clone("carol");
1672        Team {
1673            _root: root,
1674            state_dir: tempfile::tempdir().unwrap(),
1675            alice,
1676            bob,
1677            carol,
1678            origin,
1679        }
1680    }
1681
1682    fn publish_as(t: &Team, session: &str, item: Option<&str>, who: &str) -> Result<Value, String> {
1683        publish(
1684            t.state_dir.path(),
1685            PublishRequest {
1686                session_id: session,
1687                item,
1688                remote: "origin",
1689                account: who,
1690            },
1691        )
1692    }
1693
1694    /// Alice builds; returns the item id.
1695    fn built(t: &Team) -> String {
1696        let branch = coder_branch(&t.alice, "b1", &t.origin, "a.txt");
1697        let s = stage_session(
1698            t.state_dir.path(),
1699            &t.alice,
1700            CoderState::Merged,
1701            Some(&branch),
1702            None,
1703            Some(&t.origin),
1704            contract(vec![check("a", "a.txt")]),
1705        );
1706        let out = publish_as(t, &s, None, "alice").unwrap();
1707        assert_eq!(out["stage"], "build");
1708        assert_eq!(out["next_stage"], "improve");
1709        out["item_id"].as_str().unwrap().to_string()
1710    }
1711
1712    /// Bob improves from the item's tip, adding a check.
1713    fn improved(t: &Team, id: &str) -> String {
1714        let (tip, item) = prepare_stage(&t.bob, "origin", id, "bob").unwrap();
1715        assert_eq!(item.next_stage(), Stage::Improve);
1716        let branch = coder_branch(&t.bob, "i1", &tip, "b.txt");
1717        let s = stage_session(
1718            t.state_dir.path(),
1719            &t.bob,
1720            CoderState::Merged,
1721            Some(&branch),
1722            Some(&tip),
1723            None,
1724            contract(vec![check("a", "a.txt"), check("b", "b.txt")]),
1725        );
1726        publish_as(t, &s, Some(id), "bob").unwrap();
1727        tip
1728    }
1729
1730    /// Build, Improve, and a no-change Polish; returns the item id.
1731    fn polished(t: &Team) -> String {
1732        let id = built(t);
1733        improved(t, &id);
1734        let (tip, _) = prepare_stage(&t.carol, "origin", &id, "carol").unwrap();
1735        let s = stage_session(
1736            t.state_dir.path(),
1737            &t.carol,
1738            CoderState::Reported,
1739            None,
1740            Some(&tip),
1741            None,
1742            contract(vec![check("a", "a.txt"), check("b", "b.txt")]),
1743        );
1744        publish_as(t, &s, Some(&id), "carol").unwrap();
1745        id
1746    }
1747
1748    #[tokio::test]
1749    async fn a_work_item_moves_through_three_developers_and_merges() {
1750        let t = team();
1751        let id = built(&t);
1752        improved(&t, &id);
1753
1754        // Carol polishes and finds nothing to change: an accepted finding is
1755        // a valid stage, and records no work commit of its own.
1756        let (tip, _) = prepare_stage(&t.carol, "origin", &id, "carol").unwrap();
1757        let s = stage_session(
1758            t.state_dir.path(),
1759            &t.carol,
1760            CoderState::Reported,
1761            None,
1762            Some(&tip),
1763            None,
1764            contract(vec![check("a", "a.txt"), check("b", "b.txt")]),
1765        );
1766        let out = publish_as(&t, &s, Some(&id), "carol").unwrap();
1767        assert_eq!(out["stage"], "polish");
1768
1769        let listed = list(&t.carol, "origin", Some("dave")).unwrap();
1770        let row = &listed["items"][0];
1771        assert_eq!(row["item_id"], json!(id));
1772        assert_eq!(row["ready_to_merge"], true);
1773        assert_eq!(row["eligible"], true, "dave owns no stage");
1774        let listed = list(&t.carol, "origin", Some("bob")).unwrap();
1775        assert_eq!(listed["items"][0]["eligible"], false, "bob owns one");
1776
1777        let worktrees = tempfile::tempdir().unwrap();
1778        let verdict = merge_check(&t.carol, "origin", &id, worktrees.path())
1779            .await
1780            .unwrap();
1781        assert_eq!(verdict["mergeable"], true, "{verdict}");
1782        let final_branch = verdict["final_branch"].as_str().unwrap();
1783        // One squash commit on the origin, carrying the work but not the record.
1784        assert_eq!(
1785            run(&t.carol, &["rev-parse", &format!("{final_branch}^")]),
1786            t.origin
1787        );
1788        let files = run(&t.carol, &["ls-tree", "-r", "--name-only", final_branch]);
1789        assert!(
1790            files.contains("a.txt") && files.contains("b.txt"),
1791            "{files}"
1792        );
1793        assert!(!files.contains(RECORD_DIR), "{files}");
1794    }
1795
1796    #[tokio::test]
1797    async fn two_stages_are_not_mergeable() {
1798        let t = team();
1799        let id = built(&t);
1800        improved(&t, &id);
1801        let worktrees = tempfile::tempdir().unwrap();
1802        let verdict = merge_check(&t.carol, "origin", &id, worktrees.path())
1803            .await
1804            .unwrap();
1805        assert_eq!(verdict["mergeable"], false);
1806        assert!(verdict["final_branch"].is_null());
1807        assert!(
1808            verdict["problems"].to_string().contains("only 2"),
1809            "{verdict}"
1810        );
1811    }
1812
1813    /// History is re-verified at merge: a record rewritten by a plain commit
1814    /// pushed straight to the remote — bypassing `publish` — is caught, not
1815    /// trusted. Here someone "adds" two stages by hand.
1816    #[tokio::test]
1817    async fn a_hand_edited_record_fails_the_merge_check() {
1818        let t = team();
1819        let id = built(&t);
1820        let tip = fetch_item(&t.bob, "origin", &id).unwrap();
1821        let mut item = read_item(&t.bob, &tip, &id).unwrap();
1822        for (stage, who) in [(Stage::Improve, "bob"), (Stage::Polish, "carol")] {
1823            let mut forged = item.stages[0].clone();
1824            forged.stage = stage;
1825            forged.account_id = who.into();
1826            item.stages.push(forged);
1827        }
1828        run(&t.bob, &["checkout", "-q", "--detach", &tip]);
1829        std::fs::write(
1830            t.bob.join(record_path(&id)),
1831            serde_json::to_string_pretty(&item).unwrap(),
1832        )
1833        .unwrap();
1834        run(&t.bob, &["commit", "-q", "-am", "totally a record commit"]);
1835        run(
1836            &t.bob,
1837            &[
1838                "push",
1839                "-q",
1840                "origin",
1841                &format!("HEAD:refs/heads/{BRANCH_PREFIX}{id}"),
1842            ],
1843        );
1844
1845        let worktrees = tempfile::tempdir().unwrap();
1846        let verdict = merge_check(&t.carol, "origin", &id, worktrees.path())
1847            .await
1848            .unwrap();
1849        assert_eq!(verdict["mergeable"], false, "{verdict}");
1850        assert!(
1851            verdict["problems"]
1852                .to_string()
1853                .contains("not a record commit"),
1854            "{verdict}"
1855        );
1856    }
1857
1858    /// Two developers finish the same stage; the push is non-forcing, so the
1859    /// second is refused instead of overwriting the first.
1860    #[test]
1861    fn the_second_publisher_of_a_stage_loses_the_race() {
1862        let t = team();
1863        let id = built(&t);
1864        let (tip_b, _) = prepare_stage(&t.bob, "origin", &id, "bob").unwrap();
1865        let (tip_c, _) = prepare_stage(&t.carol, "origin", &id, "carol").unwrap();
1866        assert_eq!(tip_b, tip_c);
1867        let mk = |repo: &Path, tag: &str, file: &str| {
1868            let branch = coder_branch(repo, tag, &tip_b, file);
1869            stage_session(
1870                t.state_dir.path(),
1871                repo,
1872                CoderState::Merged,
1873                Some(&branch),
1874                Some(&tip_b),
1875                None,
1876                contract(vec![check("a", "a.txt")]),
1877            )
1878        };
1879        let bob = mk(&t.bob, "race-b", "b.txt");
1880        let carol = mk(&t.carol, "race-c", "c.txt");
1881        publish_as(&t, &bob, Some(&id), "bob").unwrap();
1882        let err = publish_as(&t, &carol, Some(&id), "carol").unwrap_err();
1883        assert!(
1884            err.contains("must start from the work item's tip") || err.contains("could not push"),
1885            "{err}"
1886        );
1887    }
1888
1889    /// A stage done in a developer's own terminal, outside CAR: CAR runs the
1890    /// locked contract itself, records it as unmanaged, and refuses what it
1891    /// cannot judge.
1892    /// A dirty checkout's session starts from a private snapshot of the user's
1893    /// working tree, and branch delivery re-parents the delivered commit onto
1894    /// the checkout HEAD so that work never ships. The publish gate has to
1895    /// expect that same parent: comparing against the snapshot refused an
1896    /// ordinary `car mp publish` after an ordinary dirty-checkout `car code`
1897    /// with "it has moved since approval", about a branch nobody had touched.
1898    #[test]
1899    fn a_dirty_checkout_stage_publishes_on_the_commit_its_work_was_parented_on() {
1900        let t = team();
1901        let id = built(&t);
1902        let (tip, _) = prepare_stage(&t.bob, "origin", &id, "bob").unwrap();
1903        // The snapshot CAR takes of the dirty checkout: the user's
1904        // uncommitted work, committed privately on top of the checkout HEAD.
1905        run(&t.bob, &["checkout", "-q", &tip]);
1906        std::fs::write(t.bob.join("wip.txt"), "the user's uncommitted work").unwrap();
1907        run(&t.bob, &["add", "wip.txt"]);
1908        run(&t.bob, &["commit", "-q", "-m", "car: inputs snapshot"]);
1909        let snapshot = run(&t.bob, &["rev-parse", "HEAD"]);
1910        // The delivered commit: parented on the checkout HEAD, not on the
1911        // snapshot, so `wip.txt` is not in it.
1912        let branch = coder_branch(&t.bob, "dirty", &tip, "b.txt");
1913
1914        let session = stage_session(
1915            t.state_dir.path(),
1916            &t.bob,
1917            CoderState::Merged,
1918            Some(&branch),
1919            Some(&snapshot),
1920            None,
1921            contract(vec![check("a", "a.txt")]),
1922        );
1923        let mut loaded = load_session(t.state_dir.path(), &session).unwrap();
1924        // `load_session` returns a session with no `state_dir`, and `persist`
1925        // is a no-op without one — so point it back at the store first.
1926        loaded.state_dir = Some(t.state_dir.path().to_path_buf());
1927        loaded.inputs_snapshot = Some(snapshot.clone());
1928        loaded.persist().unwrap();
1929        assert_eq!(
1930            load_session(t.state_dir.path(), &session)
1931                .unwrap()
1932                .inputs_snapshot
1933                .as_deref(),
1934            Some(snapshot.as_str()),
1935            "the fixture must actually record the snapshot it is testing"
1936        );
1937
1938        let out = publish_as(&t, &session, Some(&id), "bob").unwrap();
1939        assert_eq!(out["stage"], "improve");
1940        let published = run(&t.bob, &["rev-parse", &format!("car/mp/{id}^")]);
1941        assert_eq!(
1942            published,
1943            run(&t.bob, &["rev-parse", &branch]),
1944            "the stage carries the reviewed commit"
1945        );
1946        assert!(
1947            run(&t.bob, &["log", "--format=%H", &format!("car/mp/{id}")])
1948                .lines()
1949                .all(|commit| commit != snapshot),
1950            "the user's snapshot must not ride into the item"
1951        );
1952
1953        // Negative control: the same shapes with no snapshot recorded still
1954        // refuse, so this test cannot pass with the gate removed.
1955        let unsnapshotted = stage_session(
1956            t.state_dir.path(),
1957            &t.bob,
1958            CoderState::Merged,
1959            Some(&branch),
1960            Some(&snapshot),
1961            None,
1962            contract(vec![check("a", "a.txt")]),
1963        );
1964        let err = publish_as(&t, &unsnapshotted, Some(&id), "bob").unwrap_err();
1965        assert!(err.contains("has moved since approval"), "{err}");
1966    }
1967
1968    #[tokio::test]
1969    async fn a_stage_done_outside_car_is_verified_before_it_is_recorded() {
1970        let t = team();
1971        let id = built(&t);
1972        let tip = fetch_item(&t.bob, "origin", &id).unwrap();
1973        let worktrees = tempfile::tempdir().unwrap();
1974        fn submit<'a>(
1975            t: &'a Team,
1976            id: &'a str,
1977            commit: &'a str,
1978            additions: Vec<ContractCheck>,
1979        ) -> SubmitRequest<'a> {
1980            SubmitRequest {
1981                repo: &t.bob,
1982                item: id,
1983                commit,
1984                remote: "origin",
1985                account: "bob",
1986                contract_additions: additions,
1987            }
1988        }
1989
1990        // Nothing changed: refused, not adjudicated.
1991        let err = submit_stage(submit(&t, &id, &tip, vec![]), worktrees.path())
1992            .await
1993            .unwrap_err();
1994        assert!(err.contains("must change something"), "{err}");
1995
1996        // Red: the added check names a file the work does not create.
1997        let branch = coder_branch(&t.bob, "own-terminal", &tip, "b.txt");
1998        let head = run(&t.bob, &["rev-parse", &branch]);
1999        let err = submit_stage(
2000            submit(&t, &id, &head, vec![check("c", "c.txt")]),
2001            worktrees.path(),
2002        )
2003        .await
2004        .unwrap_err();
2005        assert!(err.contains("not green") && err.contains("c"), "{err}");
2006
2007        // A duplicate name is a replacement, not an addition.
2008        let err = submit_stage(
2009            submit(&t, &id, &head, vec![check("a", "b.txt")]),
2010            worktrees.path(),
2011        )
2012        .await
2013        .unwrap_err();
2014        assert!(err.contains("may only add checks"), "{err}");
2015
2016        // Green with a real addition: recorded, unmanaged, contract grown.
2017        let out = submit_stage(
2018            submit(&t, &id, &head, vec![check("b", "b.txt")]),
2019            worktrees.path(),
2020        )
2021        .await
2022        .unwrap();
2023        assert_eq!(out["stage"], "improve");
2024        let item = read_item(&t.bob, out["commit"].as_str().unwrap(), &id).unwrap();
2025        let stage = item.stages.last().unwrap();
2026        assert_eq!(stage.engine, "external-unmanaged");
2027        assert_eq!(stage.session_id, None);
2028        assert_eq!(stage.signals.checks_added, 1);
2029        assert_eq!(item.contract.checks.len(), 2);
2030    }
2031
2032    /// Polish is published, then someone pushes a plain code commit to the
2033    /// item branch. Nothing about it touches the record, so only the rule that
2034    /// the tip IS the last record commit can catch it — and the squash would
2035    /// otherwise carry unreviewed code into the pull request.
2036    #[tokio::test]
2037    async fn code_pushed_after_the_last_stage_fails_the_merge_check() {
2038        let t = team();
2039        let id = polished(&t);
2040        let tip = fetch_item(&t.bob, "origin", &id).unwrap();
2041        let sneaky = coder_branch(&t.bob, "after-polish", &tip, "sneaky.txt");
2042        run(
2043            &t.bob,
2044            &[
2045                "push",
2046                "-q",
2047                "origin",
2048                &format!("{sneaky}:refs/heads/{BRANCH_PREFIX}{id}"),
2049            ],
2050        );
2051        let worktrees = tempfile::tempdir().unwrap();
2052        let verdict = merge_check(&t.carol, "origin", &id, worktrees.path())
2053            .await
2054            .unwrap();
2055        assert_eq!(verdict["mergeable"], false, "{verdict}");
2056        assert!(verdict["problems"]
2057            .to_string()
2058            .contains("pushed to the branch after"));
2059        assert!(
2060            verdict["checks"].as_array().unwrap().is_empty(),
2061            "no commands ran"
2062        );
2063    }
2064
2065    /// A commit that carries a correct-looking trailer and a valid next record
2066    /// but ALSO changes code: the trailer is forgeable, the diff is not.
2067    #[tokio::test]
2068    async fn a_forged_record_commit_that_changes_code_fails_the_merge_check() {
2069        let t = team();
2070        let id = built(&t);
2071        let tip = fetch_item(&t.bob, "origin", &id).unwrap();
2072        let mut item = read_item(&t.bob, &tip, &id).unwrap();
2073        let mut forged = item.stages[0].clone();
2074        forged.stage = Stage::Improve;
2075        forged.account_id = "bob".into();
2076        forged.base_commit = tip.clone();
2077        forged.result_commit = tip.clone();
2078        item.stages.push(forged);
2079        run(&t.bob, &["checkout", "-q", "--detach", &tip]);
2080        std::fs::write(
2081            t.bob.join(record_path(&id)),
2082            serde_json::to_string_pretty(&item).unwrap(),
2083        )
2084        .unwrap();
2085        std::fs::write(t.bob.join("backdoor.txt"), "x").unwrap();
2086        run(&t.bob, &["add", "-A"]);
2087        run(
2088            &t.bob,
2089            &[
2090                "commit",
2091                "-q",
2092                "-m",
2093                &format!("forged\n\nMultiplayer-Item: {id}"),
2094            ],
2095        );
2096        run(
2097            &t.bob,
2098            &[
2099                "push",
2100                "-q",
2101                "origin",
2102                &format!("HEAD:refs/heads/{BRANCH_PREFIX}{id}"),
2103            ],
2104        );
2105        let worktrees = tempfile::tempdir().unwrap();
2106        let verdict = merge_check(&t.carol, "origin", &id, worktrees.path())
2107            .await
2108            .unwrap();
2109        assert_eq!(verdict["mergeable"], false);
2110        assert!(
2111            verdict["problems"]
2112                .to_string()
2113                .contains("changes more than the record"),
2114            "{verdict}"
2115        );
2116    }
2117
2118    /// The approved branch is a movable name. A commit added on it after
2119    /// approval is not the reviewed work and must not publish as the stage.
2120    #[test]
2121    fn a_branch_moved_after_approval_is_refused() {
2122        let t = team();
2123        let branch = coder_branch(&t.alice, "moved", &t.origin, "a.txt");
2124        run(&t.alice, &["checkout", "-q", &branch]);
2125        std::fs::write(t.alice.join("extra.txt"), "later").unwrap();
2126        run(&t.alice, &["add", "extra.txt"]);
2127        run(&t.alice, &["commit", "-q", "-m", "after approval"]);
2128        run(&t.alice, &["checkout", "-q", "--detach"]);
2129        let s = stage_session(
2130            t.state_dir.path(),
2131            &t.alice,
2132            CoderState::Merged,
2133            Some(&branch),
2134            None,
2135            Some(&t.origin),
2136            contract(vec![check("a", "a.txt")]),
2137        );
2138        let err = publish_as(&t, &s, None, "alice").unwrap_err();
2139        assert!(err.contains("moved since approval"), "{err}");
2140    }
2141
2142    /// Build's origin is where the final squash lands; an origin that is not
2143    /// on the remote would carry unpushed commits into the PR unowned.
2144    #[test]
2145    fn a_build_from_an_unpushed_commit_is_refused() {
2146        let t = team();
2147        std::fs::write(t.alice.join("local.txt"), "unpushed").unwrap();
2148        run(&t.alice, &["add", "local.txt"]);
2149        run(&t.alice, &["commit", "-q", "-m", "local only"]);
2150        let local = run(&t.alice, &["rev-parse", "HEAD"]);
2151        let branch = coder_branch(&t.alice, "from-local", &local, "a.txt");
2152        let s = stage_session(
2153            t.state_dir.path(),
2154            &t.alice,
2155            CoderState::Merged,
2156            Some(&branch),
2157            None,
2158            Some(&local),
2159            contract(vec![check("a", "a.txt")]),
2160        );
2161        let err = publish_as(&t, &s, None, "alice").unwrap_err();
2162        assert!(err.contains("not on `origin`"), "{err}");
2163    }
2164
2165    #[test]
2166    fn a_contract_the_merge_check_could_never_run_is_refused() {
2167        let t = team();
2168        let branch = coder_branch(&t.alice, "baseline", &t.origin, "a.txt");
2169        let mut capture = check("before", "a.txt");
2170        capture.baseline = true;
2171        let s = stage_session(
2172            t.state_dir.path(),
2173            &t.alice,
2174            CoderState::Merged,
2175            Some(&branch),
2176            None,
2177            Some(&t.origin),
2178            contract(vec![check("a", "a.txt"), capture]),
2179        );
2180        let err = publish_as(&t, &s, None, "alice").unwrap_err();
2181        assert!(err.contains("baseline/differential"), "{err}");
2182    }
2183
2184    #[test]
2185    fn remote_names_are_validated() {
2186        let t = team();
2187        for bad in ["-x", "--upload-pack=touch /tmp/pwn", "nope"] {
2188            let err = list(&t.alice, bad, None).unwrap_err();
2189            assert!(
2190                err.contains("invalid remote") || err.contains("no remote named"),
2191                "{bad}: {err}"
2192            );
2193        }
2194    }
2195
2196    /// Publishing uses a private index: whatever the developer has staged or
2197    /// changed in their checkout is exactly as it was afterwards.
2198    #[test]
2199    fn publish_leaves_the_developers_index_and_checkout_alone() {
2200        let t = team();
2201        let branch = coder_branch(&t.alice, "idx", &t.origin, "a.txt");
2202        run(&t.alice, &["checkout", "-q", "main"]);
2203        std::fs::write(t.alice.join("staged.txt"), "mine").unwrap();
2204        run(&t.alice, &["add", "staged.txt"]);
2205        std::fs::write(t.alice.join("README"), "edited, unstaged").unwrap();
2206        let before = (
2207            run(&t.alice, &["diff", "--cached", "--name-only"]),
2208            run(&t.alice, &["status", "--porcelain"]),
2209            run(&t.alice, &["rev-parse", "HEAD"]),
2210        );
2211        let s = stage_session(
2212            t.state_dir.path(),
2213            &t.alice,
2214            CoderState::Merged,
2215            Some(&branch),
2216            None,
2217            Some(&t.origin),
2218            contract(vec![check("a", "a.txt")]),
2219        );
2220        publish_as(&t, &s, None, "alice").unwrap();
2221        let after = (
2222            run(&t.alice, &["diff", "--cached", "--name-only"]),
2223            run(&t.alice, &["status", "--porcelain"]),
2224            run(&t.alice, &["rev-parse", "HEAD"]),
2225        );
2226        assert_eq!(before, after);
2227    }
2228
2229    /// The real race: two record commits built from the same tip. The push is
2230    /// non-forcing, so the second is refused at the remote.
2231    #[test]
2232    fn the_push_itself_refuses_a_second_record_from_the_same_tip() {
2233        let t = team();
2234        let id = built(&t);
2235        let tip = fetch_item(&t.bob, "origin", &id).unwrap();
2236        let item = read_item(&t.bob, &tip, &id).unwrap();
2237        // Two different stage records, as two real publishers would write.
2238        let with_owner = |who: &str| {
2239            let mut next = item.clone();
2240            let mut stage = next.stages[0].clone();
2241            stage.stage = Stage::Improve;
2242            stage.account_id = who.into();
2243            next.stages.push(stage);
2244            next
2245        };
2246        commit_and_push(&t.bob, "origin", &with_owner("bob"), Stage::Improve, &tip).unwrap();
2247        run(&t.carol, &["fetch", "-q", "origin"]);
2248        let err = commit_and_push(
2249            &t.carol,
2250            "origin",
2251            &with_owner("carol"),
2252            Stage::Improve,
2253            &tip,
2254        )
2255        .unwrap_err();
2256        assert!(err.contains("could not push"), "{err}");
2257    }
2258
2259    /// The contract's prose is not a hand-off channel: the record stores the
2260    /// item's intent in its place, whatever the stage wrote.
2261    #[test]
2262    fn the_record_keeps_the_intent_not_the_stages_prose() {
2263        let t = team();
2264        let id = built(&t);
2265        let tip = fetch_item(&t.bob, "origin", &id).unwrap();
2266        let item = read_item(&t.bob, &tip, &id).unwrap();
2267        assert_eq!(item.contract.description, item.intent);
2268        assert_ne!(item.contract.description, "files exist");
2269    }
2270
2271    #[tokio::test]
2272    async fn a_submission_must_descend_from_the_tip_and_leave_the_record_alone() {
2273        let t = team();
2274        let id = built(&t);
2275        let tip = fetch_item(&t.bob, "origin", &id).unwrap();
2276        let worktrees = tempfile::tempdir().unwrap();
2277        let off_tip = coder_branch(&t.bob, "off-tip", &t.origin, "b.txt");
2278        let off = run(&t.bob, &["rev-parse", &off_tip]);
2279        let err = submit_stage(
2280            SubmitRequest {
2281                repo: &t.bob,
2282                item: &id,
2283                commit: &off,
2284                remote: "origin",
2285                account: "bob",
2286                contract_additions: vec![],
2287            },
2288            worktrees.path(),
2289        )
2290        .await
2291        .unwrap_err();
2292        assert!(err.contains("does not descend"), "{err}");
2293
2294        let touch = coder_branch(&t.bob, "touch-record", &tip, &record_path(&id));
2295        let touched = run(&t.bob, &["rev-parse", &touch]);
2296        let err = submit_stage(
2297            SubmitRequest {
2298                repo: &t.bob,
2299                item: &id,
2300                commit: &touched,
2301                remote: "origin",
2302                account: "bob",
2303                contract_additions: vec![],
2304            },
2305            worktrees.path(),
2306        )
2307        .await
2308        .unwrap_err();
2309        assert!(err.contains("only the runtime writes it"), "{err}");
2310    }
2311
2312    #[test]
2313    fn the_builder_cannot_also_improve() {
2314        let t = team();
2315        let id = built(&t);
2316        let err = prepare_stage(&t.alice, "origin", &id, "alice").unwrap_err();
2317        assert!(err.contains("already owns a stage"), "{err}");
2318        // publish enforces it too, not only the start-time check.
2319        let tip = fetch_item(&t.alice, "origin", &id).unwrap();
2320        let branch = coder_branch(&t.alice, "i2", &tip, "b.txt");
2321        let s = stage_session(
2322            t.state_dir.path(),
2323            &t.alice,
2324            CoderState::Merged,
2325            Some(&branch),
2326            Some(&tip),
2327            None,
2328            contract(vec![check("a", "a.txt")]),
2329        );
2330        let err = publish_as(&t, &s, Some(&id), "alice").unwrap_err();
2331        assert!(err.contains("already owns a stage"), "{err}");
2332    }
2333
2334    #[test]
2335    fn a_stage_may_not_drop_or_change_a_locked_check() {
2336        let t = team();
2337        let id = built(&t);
2338        let (tip, _) = prepare_stage(&t.bob, "origin", &id, "bob").unwrap();
2339        let branch = coder_branch(&t.bob, "i3", &tip, "b.txt");
2340        let dropped = stage_session(
2341            t.state_dir.path(),
2342            &t.bob,
2343            CoderState::Merged,
2344            Some(&branch),
2345            Some(&tip),
2346            None,
2347            contract(vec![check("b", "b.txt")]),
2348        );
2349        let err = publish_as(&t, &dropped, Some(&id), "bob").unwrap_err();
2350        assert!(err.contains("drops the locked check `a`"), "{err}");
2351
2352        let changed = stage_session(
2353            t.state_dir.path(),
2354            &t.bob,
2355            CoderState::Merged,
2356            Some(&branch),
2357            Some(&tip),
2358            None,
2359            contract(vec![check("a", "b.txt")]),
2360        );
2361        let err = publish_as(&t, &changed, Some(&id), "bob").unwrap_err();
2362        assert!(err.contains("changes the locked check `a`"), "{err}");
2363    }
2364
2365    #[test]
2366    fn a_stage_must_start_from_the_tip_and_leave_the_record_alone() {
2367        let t = team();
2368        let id = built(&t);
2369        // Started from the origin, not the item's tip.
2370        let branch = coder_branch(&t.bob, "i4", &t.origin, "b.txt");
2371        let s = stage_session(
2372            t.state_dir.path(),
2373            &t.bob,
2374            CoderState::Merged,
2375            Some(&branch),
2376            Some(&t.origin),
2377            None,
2378            contract(vec![check("a", "a.txt")]),
2379        );
2380        let err = publish_as(&t, &s, Some(&id), "bob").unwrap_err();
2381        assert!(err.contains("must start from the work item's tip"), "{err}");
2382
2383        // Started correctly, but edited the record.
2384        let (tip, _) = prepare_stage(&t.bob, "origin", &id, "bob").unwrap();
2385        let record = record_path(&id);
2386        let branch = coder_branch(&t.bob, "i5", &tip, &record);
2387        let s = stage_session(
2388            t.state_dir.path(),
2389            &t.bob,
2390            CoderState::Merged,
2391            Some(&branch),
2392            Some(&tip),
2393            None,
2394            contract(vec![check("a", "a.txt")]),
2395        );
2396        let err = publish_as(&t, &s, Some(&id), "bob").unwrap_err();
2397        assert!(err.contains("only the runtime writes it"), "{err}");
2398    }
2399
2400    #[test]
2401    fn build_needs_a_diff_and_a_session_that_finished() {
2402        let t = team();
2403        let s = stage_session(
2404            t.state_dir.path(),
2405            &t.alice,
2406            CoderState::Reported,
2407            None,
2408            None,
2409            Some(&t.origin),
2410            contract(vec![check("a", "a.txt")]),
2411        );
2412        let err = publish_as(&t, &s, None, "alice").unwrap_err();
2413        assert!(err.contains("Build has to build something"), "{err}");
2414
2415        let s = stage_session(
2416            t.state_dir.path(),
2417            &t.alice,
2418            CoderState::NeedsApproval,
2419            None,
2420            None,
2421            Some(&t.origin),
2422            contract(vec![check("a", "a.txt")]),
2423        );
2424        let err = publish_as(&t, &s, None, "alice").unwrap_err();
2425        assert!(err.contains("needs_approval"), "{err}");
2426    }
2427
2428    #[test]
2429    fn a_record_with_an_unknown_field_is_refused() {
2430        let item = json!({
2431            "schema_version": 1, "id": "mp-0123456789abcdef", "repo_root_commit": "r",
2432            "origin_commit": "o", "intent": "i",
2433            "contract": { "description": "d", "checks": [] },
2434            "stages": [], "handoff_notes": "here is why I did it this way"
2435        });
2436        let err = serde_json::from_value::<WorkItem>(item).unwrap_err();
2437        assert!(err.to_string().contains("handoff_notes"), "{err}");
2438    }
2439
2440    #[test]
2441    fn contract_growth_rules() {
2442        let a = contract(vec![check("a", "a.txt")]);
2443        let ab = contract(vec![check("a", "a.txt"), check("b", "b.txt")]);
2444        assert_eq!(contract_grows(&a, &ab), Ok(1));
2445        assert_eq!(contract_grows(&a, &a), Ok(0));
2446        assert!(contract_grows(&ab, &a).is_err());
2447        let mut creds = ab.clone();
2448        creds.allow_credentials = true;
2449        assert!(contract_grows(&ab, &creds)
2450            .unwrap_err()
2451            .contains("credential"));
2452        assert_eq!(
2453            contract_hash(&ab),
2454            contract_hash(&contract(vec![check("b", "b.txt"), check("a", "a.txt")])),
2455            "order-insensitive"
2456        );
2457        assert_ne!(contract_hash(&a), contract_hash(&ab));
2458    }
2459
2460    #[test]
2461    fn item_ids_are_validated_before_reaching_git() {
2462        assert!(valid_item_id("mp-0123456789abcdef"));
2463        for bad in [
2464            "mp-0123",
2465            "mp-0123456789ABCDEF",
2466            "--upload-pack=x",
2467            "mp-0123456789abcdeg",
2468        ] {
2469            assert!(!valid_item_id(bad), "{bad}");
2470        }
2471    }
2472}