Skip to main content

choir_cli/
runner.rs

1//! One runner seam: the part of an orchestrator adapter that is not about
2//! the orchestrator.
3//!
4//! Two adapters exist, for Claude Code's worktree hooks and for a
5//! Symphony workspace backend. Written independently, in shell, they
6//! converged on the same five steps: derive a stable identity from
7//! whatever the orchestrator calls its unit of work, resolve an exact
8//! base revision, drive the lifecycle, *verify the binding that came
9//! back is the one that was asked for*, and map Choir's typed rejections
10//! onto a retry decision.
11//!
12//! Only the first and last of those are orchestrator-shaped, and only
13//! barely. The rest is the lifecycle contract, and it was duplicated: 557
14//! lines of shell holding two hand-rolled copies of the identity
15//! derivation and the binding check, which are exactly the parts where a
16//! mistake is silent. A workspace name that collides sends two agents
17//! into one directory. A binding check that passes vacuously accepts a
18//! workspace bound to somebody else's change.
19//!
20//! So the shared half lives here, in one tested place, and an adapter is
21//! left with what genuinely differs: its wire format, and its namespace.
22//!
23//! # What is deliberately not here
24//!
25//! No orchestrator's field names, and no agent protocol's concepts. The
26//! design note is explicit that one agent protocol must not become
27//! Choir's change model, so this module speaks only in the four
28//! identifiers the identifier contract already names: workspace, change,
29//! revision and operation.
30
31use choir_hash::ContentHash;
32
33/// Wire-format version of the runner request and result.
34pub const PROTOCOL_VERSION: u64 = 1;
35
36/// Longest accepted external identifier, in bytes.
37///
38/// A tracker id or a branch name is short. Something arriving here at
39/// kilobyte scale is a caller error or an attempt to push the derived
40/// name somewhere it does not fit, and both are better refused at the
41/// boundary than truncated into a collision.
42const MAX_IDENTIFIER: usize = 512;
43
44/// Longest accepted workspace key, which becomes part of a directory
45/// name and so is bounded well under any filesystem's component limit.
46const MAX_WORKSPACE_KEY: usize = 160;
47
48/// How much of the workspace key survives into the directory name.
49const KEY_PREFIX: usize = 80;
50
51/// How many hex characters of the fingerprint disambiguate the name.
52///
53/// 16 hex characters is 64 bits. The fingerprint already guarantees
54/// uniqueness through `change_id`; this suffix only has to stop two
55/// human-chosen keys from colliding in the filesystem, and a birthday
56/// collision at 64 bits needs about four billion live workspaces on one
57/// repository.
58const FINGERPRINT_IN_NAME: usize = 16;
59
60/// A refused request: a stable code, whether retrying can help, and a
61/// message safe to hand back to the orchestrator.
62///
63/// `retryable` is the field an orchestrator actually branches on, so it
64/// is computed rather than guessed. Getting it wrong in the safe-looking
65/// direction is what hurts: a terminal failure marked retryable becomes
66/// a scheduler spinning on a request that can never succeed.
67#[derive(Debug, Clone, PartialEq, Eq)]
68pub struct Failure {
69    /// Stable machine-readable code.
70    pub code: String,
71    /// Whether an identical retry could succeed later.
72    pub retryable: bool,
73    /// Human-readable detail.
74    pub message: String,
75}
76
77impl Failure {
78    /// A terminal failure: retrying the identical request cannot help.
79    #[must_use]
80    pub fn terminal(code: &str, message: impl Into<String>) -> Self {
81        Self {
82            code: code.to_string(),
83            retryable: false,
84            message: message.into(),
85        }
86    }
87
88    /// A transient failure: the same request may succeed later.
89    #[must_use]
90    pub fn transient(code: &str, message: impl Into<String>) -> Self {
91        Self {
92            code: code.to_string(),
93            retryable: true,
94            message: message.into(),
95        }
96    }
97
98    /// The wire form an adapter forwards to its orchestrator.
99    #[must_use]
100    pub fn to_json(&self) -> serde_json::Value {
101        serde_json::json!({
102            "protocol_version": PROTOCOL_VERSION,
103            "error": {
104                "code": self.code,
105                "retryable": self.retryable,
106                "message": self.message,
107            }
108        })
109    }
110}
111
112/// Whether a Choir rejection code can succeed on an identical retry.
113///
114/// The listed codes are decisions about the request itself: a binding
115/// that conflicts, a signature that does not verify, a channel the key
116/// does not own. None of them change because time passed, so retrying is
117/// a scheduler burning attempts on a refusal that is already final.
118///
119/// Everything else, including an unrecognised code, is treated as
120/// transient. That asymmetry is deliberate. Calling a transient failure
121/// terminal strands work that would have succeeded, while calling a
122/// terminal one transient costs retries that fail fast and loudly. A
123/// code this build has never heard of is more likely a newer node than a
124/// new class of permanent refusal.
125#[must_use]
126pub fn is_retryable(code: &str) -> bool {
127    !matches!(
128        code,
129        "workspace_state"
130            | "change_state"
131            | "stale_head"
132            | "malformed_request"
133            | "malformed_op"
134            | "unknown_key"
135            | "channel_not_owned"
136            | "identity_state"
137            | "bad_signature"
138            | "node_only"
139            | "duplicate_submission"
140            | "foreign_scope"
141    )
142}
143
144/// Whether one path component is safe to place in a workspace path.
145///
146/// Leading dot excluded, so a derived name can never produce a hidden
147/// entry or either dot directory.
148#[must_use]
149pub fn safe_segment(segment: &str) -> bool {
150    !segment.is_empty()
151        && segment
152            .chars()
153            .next()
154            .is_some_and(|c| c.is_ascii_alphanumeric())
155        && segment
156            .chars()
157            .all(|c| c.is_ascii_alphanumeric() || c == '.' || c == '_' || c == '-')
158}
159
160/// Splits `owner/repo`, refusing any other shape.
161///
162/// # Errors
163///
164/// Returns a [`Failure`] when the name is not exactly two safe segments.
165pub fn split_repo(repo: &str) -> Result<(&str, &str), Failure> {
166    let mut segments = repo.split('/');
167    let (Some(owner), Some(name), None) = (segments.next(), segments.next(), segments.next())
168    else {
169        return Err(Failure::terminal(
170            "invalid_config",
171            "repo must be owner/repo",
172        ));
173    };
174    if !safe_segment(owner) || !safe_segment(name) {
175        return Err(Failure::terminal(
176            "invalid_config",
177            "repo contains an unsafe path segment",
178        ));
179    }
180    Ok((owner, name))
181}
182
183/// Marker for the derivation rules themselves, mixed into every
184/// fingerprint.
185///
186/// A change to how identity is derived must produce different ids by
187/// construction rather than by luck, so that an adapter holding durable
188/// state from an older build sees a mismatch it can refuse. Silently
189/// deriving a *different* id for the same work is how one unit of work
190/// forks into two changes and two workspaces.
191const SCHEME_TAG: &str = "choir-runner-1";
192
193/// How a change's identity is derived, and what that choice costs.
194///
195/// Not a preference. Each orchestrator's own contract forces one of
196/// these, and picking the other silently breaks a lifecycle operation.
197#[derive(Debug, Clone, Copy, PartialEq, Eq)]
198pub enum Scheme {
199    /// The workspace name *is* the identity: the change id is derived
200    /// from it, so any caller holding the path can recompute the binding
201    /// with no stored state.
202    ///
203    /// Required when the orchestrator hands back only a directory on
204    /// teardown, which is exactly Claude Code's `WorktreeRemove`. The
205    /// cost is that the binding does not survive a rename, and that two
206    /// attempts at one unit of work are two unrelated changes unless the
207    /// caller's naming already says otherwise.
208    FromName,
209    /// The identity is fingerprinted from stable external identity, and
210    /// the workspace name is a derived label.
211    ///
212    /// Required when competing workers must converge on one change for
213    /// one unit of work, which is what a scheduler retry needs. The cost
214    /// is that the full fingerprint does not fit in the directory name,
215    /// so recovering the binding from a path alone is impossible and the
216    /// adapter must keep durable state.
217    FromExternal,
218}
219
220impl Scheme {
221    /// The stable spelling recorded in adapter state.
222    #[must_use]
223    pub fn as_str(self) -> &'static str {
224        match self {
225            Self::FromName => "from-name",
226            Self::FromExternal => "from-external",
227        }
228    }
229}
230
231/// The four identifiers one writing attempt is bound to.
232///
233/// Built together and never separately, because their whole value is
234/// that they agree: the workspace a change is bound to, the change a
235/// checkpoint advances, and the idempotency key that makes a lost create
236/// response converge rather than fork.
237#[derive(Debug, Clone, PartialEq, Eq)]
238pub struct Identity {
239    /// Directory name.
240    pub workspace_name: String,
241    /// `owner/repo/name`, the workspace's view identity.
242    pub workspace_id: String,
243    /// Stable logical change id, unique per external unit of work.
244    pub change_id: String,
245    /// Owner-scoped create retry identity.
246    pub idempotency_key: String,
247    /// Full hex fingerprint, under [`Scheme::FromExternal`] only. Empty
248    /// under [`Scheme::FromName`], where the name carries the identity.
249    pub fingerprint: String,
250    /// Which rule produced this binding. Recorded so an adapter reading
251    /// durable state written by another scheme refuses it rather than
252    /// deriving a second change for work that already has one.
253    pub scheme: Scheme,
254}
255
256/// Checks a namespace is usable in both a path and an identifier.
257fn checked_namespace(namespace: &str) -> Result<(), Failure> {
258    if safe_segment(namespace) {
259        Ok(())
260    } else {
261        Err(Failure::terminal(
262            "invalid_request",
263            "namespace must be a safe path segment",
264        ))
265    }
266}
267
268impl Identity {
269    /// Derives the binding from the workspace name, under
270    /// [`Scheme::FromName`].
271    ///
272    /// The inverse is what makes this scheme worth having: given only
273    /// `repo` and a directory name, the change and idempotency key come
274    /// back exactly, so teardown needs no stored state. Claude Code's
275    /// `WorktreeRemove` is handed a path and nothing else, and this is
276    /// the property that lets it archive the right change.
277    ///
278    /// # Errors
279    ///
280    /// Returns a [`Failure`] when the repo or the name is not a safe
281    /// path component.
282    pub fn from_name(namespace: &str, repo: &str, workspace_name: &str) -> Result<Self, Failure> {
283        split_repo(repo)?;
284        checked_namespace(namespace)?;
285        if !safe_segment(workspace_name) || workspace_name.len() > MAX_WORKSPACE_KEY {
286            return Err(Failure::terminal(
287                "invalid_request",
288                "workspace name is unsafe or too long",
289            ));
290        }
291        Ok(Self {
292            workspace_id: format!("{repo}/{workspace_name}"),
293            change_id: format!("{namespace}:{repo}:{workspace_name}"),
294            idempotency_key: format!("{namespace}-create:{repo}:{workspace_name}"),
295            workspace_name: workspace_name.to_string(),
296            fingerprint: String::new(),
297            scheme: Scheme::FromName,
298        })
299    }
300
301    /// Derives the binding by fingerprinting external identity, under
302    /// [`Scheme::FromExternal`].
303    ///
304    /// `namespace` separates orchestrators, so two of them driving the
305    /// same repository cannot collide on a name or, worse, converge onto
306    /// one another's change. `external_id` and `generation` are whatever
307    /// the orchestrator means by "this unit of work" and "this attempt
308    /// at it": a tracker issue and a retry counter.
309    ///
310    /// Two workers racing the same `(external_id, generation)` derive
311    /// the same change and the same idempotency key, which is what makes
312    /// one of them reuse the other's workspace instead of forking the
313    /// work in two. That convergence is the whole reason to pay for
314    /// durable state.
315    ///
316    /// The fingerprint covers the scheme tag, `repo`, `external_id` and
317    /// `generation`, each length-prefixed, so no choice of separators
318    /// inside a field can make two different tuples hash alike.
319    ///
320    /// # Errors
321    ///
322    /// Returns a [`Failure`] when any input is empty, over-long, or
323    /// unsafe as a path component.
324    pub fn from_external(
325        namespace: &str,
326        repo: &str,
327        workspace_key: &str,
328        external_id: &str,
329        generation: &str,
330    ) -> Result<Self, Failure> {
331        split_repo(repo)?;
332        checked_namespace(namespace)?;
333        if !safe_segment(workspace_key) || workspace_key.len() > MAX_WORKSPACE_KEY {
334            return Err(Failure::terminal(
335                "invalid_request",
336                "workspace_key is unsafe or too long",
337            ));
338        }
339        for (field, value) in [("external_id", external_id), ("generation", generation)] {
340            if value.is_empty() || value.len() > MAX_IDENTIFIER {
341                return Err(Failure::terminal(
342                    "invalid_request",
343                    format!("{field} must be non-empty and at most {MAX_IDENTIFIER} bytes"),
344                ));
345            }
346        }
347
348        // Length-prefixed so a separator inside any field cannot forge a
349        // different tuple with the same bytes.
350        let mut material = Vec::new();
351        for field in [SCHEME_TAG, repo, external_id, generation] {
352            material.extend_from_slice(field.len().to_string().as_bytes());
353            material.push(b':');
354            material.extend_from_slice(field.as_bytes());
355            material.push(b'\n');
356        }
357        let hashed = ContentHash::blake3(&material).to_hex();
358        // `to_hex` is `<codec>-<digest>`; the ids want the digest alone.
359        let digest = hashed
360            .split_once('-')
361            .map_or(hashed.as_str(), |(_, rest)| rest);
362
363        let prefix: String = workspace_key.chars().take(KEY_PREFIX).collect();
364        let short: String = digest.chars().take(FINGERPRINT_IN_NAME).collect();
365        let workspace_name = format!("{namespace}-{prefix}-{short}");
366
367        Ok(Self {
368            workspace_id: format!("{repo}/{workspace_name}"),
369            workspace_name,
370            change_id: format!("{namespace}:{repo}:{digest}"),
371            idempotency_key: format!("{namespace}-create:{repo}:{digest}"),
372            fingerprint: digest.to_string(),
373            scheme: Scheme::FromExternal,
374        })
375    }
376}
377
378/// Extracts the exact Git object id `base_ref` currently points at.
379///
380/// The view reports a revision as `<codec>-<digest>`; codecs `11` and
381/// `12` are git SHA-1 and SHA-256. A revision under any other codec is
382/// refused rather than coerced, because a workspace must start from an
383/// object git can actually check out.
384///
385/// # Errors
386///
387/// Returns a [`Failure`] when the ref is absent, carries a non-Git
388/// revision, or names an object id that is not hex of a git width.
389pub fn base_from_view(view: &serde_json::Value, base_ref: &str) -> Result<String, Failure> {
390    let Some(revision) = view
391        .get("refs")
392        .and_then(|refs| refs.get(base_ref))
393        .and_then(serde_json::Value::as_str)
394    else {
395        return Err(Failure::terminal(
396            "base_ref_missing",
397            format!("{base_ref} is absent from the Choir view"),
398        ));
399    };
400    let Some(oid) = revision
401        .strip_prefix("11-")
402        .or_else(|| revision.strip_prefix("12-"))
403    else {
404        return Err(Failure::terminal(
405            "base_ref_invalid",
406            format!("{base_ref} names a non-Git revision"),
407        ));
408    };
409    let git_width = oid.len() == 40 || oid.len() == 64;
410    if !git_width || !oid.chars().all(|c| c.is_ascii_hexdigit()) {
411        return Err(Failure::terminal(
412            "base_ref_invalid",
413            format!("{base_ref} names an invalid Git object id"),
414        ));
415    }
416    Ok(oid.to_string())
417}
418
419/// The base revision the node says the change is actually bound to.
420///
421/// Creation is idempotent, so a retry that resolved a `base_ref` which
422/// has since moved reuses the existing change at its original base.
423/// Reporting the requested revision there would describe a workspace
424/// that does not exist: the orchestrator would record one base while the
425/// change is bound to another, and every later comparison against it
426/// would be wrong. The node's answer is authoritative; `requested` is
427/// only a fallback for a response that carries none.
428#[must_use]
429pub fn bound_base(response: &serde_json::Value, requested: &str) -> String {
430    let reported = response
431        .get("base_revision")
432        .and_then(serde_json::Value::as_str)
433        .and_then(|value| {
434            value
435                .strip_prefix("11-")
436                .or_else(|| value.strip_prefix("12-"))
437        })
438        .filter(|oid| oid.len() == 40 || oid.len() == 64)
439        .filter(|oid| oid.chars().all(|c| c.is_ascii_hexdigit()));
440    reported.map_or_else(|| requested.to_string(), str::to_string)
441}
442
443/// Checks the node returned the binding that was asked for.
444///
445/// This is the step whose absence is silent. Creation is idempotent by
446/// design, so a request that reuses an existing workspace answers 200
447/// with a receipt; without comparing identities, a receipt for somebody
448/// else's change reads exactly like success, and the adapter hands an
449/// agent a directory bound to a change it may not checkpoint.
450///
451/// # Errors
452///
453/// Returns a [`Failure`] when either identity is missing from the
454/// response or differs from the expected one.
455pub fn verify_binding(response: &serde_json::Value, expected: &Identity) -> Result<(), Failure> {
456    let field = |key: &str| -> Result<String, Failure> {
457        response
458            .get(key)
459            .and_then(serde_json::Value::as_str)
460            .filter(|value| !value.is_empty())
461            .map(str::to_string)
462            .ok_or_else(|| {
463                Failure::transient(
464                    "invalid_response",
465                    format!("Choir returned no {key} to check the binding against"),
466                )
467            })
468    };
469    let (workspace, change) = (field("workspace")?, field("change_id")?);
470    if workspace != expected.workspace_id || change != expected.change_id {
471        return Err(Failure::terminal(
472            "binding_mismatch",
473            format!(
474                "Choir returned workspace {workspace} change {change}, \
475                 expected workspace {} change {}",
476                expected.workspace_id, expected.change_id
477            ),
478        ));
479    }
480    Ok(())
481}
482
483/// Turns a Choir error response into a [`Failure`], preserving its code.
484///
485/// A body that does not parse as a typed rejection still has to produce
486/// something an orchestrator can branch on, so it becomes a transient
487/// `choir_unavailable` rather than being dropped.
488#[must_use]
489pub fn failure_from_response(body: &str, context: &str) -> Failure {
490    let parsed: Option<serde_json::Value> = serde_json::from_str(body).ok();
491    let code = parsed
492        .as_ref()
493        .and_then(|value| value.get("code"))
494        .and_then(serde_json::Value::as_str);
495    let detail = parsed
496        .as_ref()
497        .and_then(|value| {
498            value
499                .get("detail")
500                .or_else(|| value.get("error"))
501                .and_then(serde_json::Value::as_str)
502        })
503        .map(str::to_string);
504    match code {
505        Some(code) => Failure {
506            code: code.to_string(),
507            retryable: is_retryable(code),
508            message: detail.unwrap_or_else(|| format!("Choir did not complete {context}")),
509        },
510        None => Failure::transient(
511            "choir_unavailable",
512            detail.unwrap_or_else(|| format!("Choir did not complete {context}")),
513        ),
514    }
515}
516
517/// One lifecycle step an orchestrator asks for.
518#[derive(Debug, Clone, Copy, PartialEq, Eq)]
519pub enum Operation {
520    /// Bind a workspace and change at an exact base, idempotently.
521    Ensure,
522    /// Publish an immutable revision of the bound change.
523    Checkpoint,
524    /// Owner-authorized recoverable archive of the bound workspace.
525    Archive,
526}
527
528impl Operation {
529    fn parse(value: &str) -> Result<Self, Failure> {
530        match value {
531            "ensure" => Ok(Self::Ensure),
532            "checkpoint" => Ok(Self::Checkpoint),
533            "archive" => Ok(Self::Archive),
534            other => Err(Failure::terminal(
535                "unsupported_operation",
536                format!("operation must be ensure, checkpoint or archive, not {other}"),
537            )),
538        }
539    }
540}
541
542/// Install-time settings: the node, the repository, and the owner
543/// identity that signs. Supplied by the operator, not by the
544/// orchestrator, so a request cannot redirect a workspace at another
545/// repository or sign with another key.
546#[derive(Debug, Clone, PartialEq, Eq)]
547pub struct Config {
548    /// Node API base URL.
549    pub api: String,
550    /// Repository as `owner/repo`.
551    pub repo: String,
552    /// Owner channel that signs lifecycle authorizations.
553    pub owner: String,
554    /// Absolute path to the owner's 32-byte key file.
555    pub key_file: String,
556    /// Identifier namespace separating this orchestrator from others.
557    pub namespace: String,
558    /// View ref an `ensure` resolves its base revision from.
559    pub base_ref: Option<String>,
560    /// Basic-auth credentials file, absolute when present.
561    pub auth_file: Option<String>,
562    /// Basic-auth username; needs `auth_file`.
563    pub auth_user: Option<String>,
564}
565
566fn string_field(object: &serde_json::Value, key: &str) -> Option<String> {
567    object
568        .get(key)
569        .and_then(serde_json::Value::as_str)
570        .filter(|value| !value.is_empty())
571        .map(str::to_string)
572}
573
574impl Config {
575    /// Parses and validates operator configuration.
576    ///
577    /// Every path is required to be absolute, because the adapter is
578    /// invoked by a scheduler whose working directory is its own
579    /// business, and a relative key path would resolve somewhere nobody
580    /// chose.
581    ///
582    /// # Errors
583    ///
584    /// Returns a [`Failure`] naming the first field that is missing or
585    /// unusable.
586    pub fn parse(value: &serde_json::Value) -> Result<Self, Failure> {
587        let missing = |field: &str| {
588            Failure::terminal("invalid_config", format!("config needs string {field}"))
589        };
590        let api = string_field(value, "api").ok_or_else(|| missing("api"))?;
591        let repo = string_field(value, "repo").ok_or_else(|| missing("repo"))?;
592        let owner = string_field(value, "owner").ok_or_else(|| missing("owner"))?;
593        let key_file = string_field(value, "key_file").ok_or_else(|| missing("key_file"))?;
594        let namespace = string_field(value, "namespace").ok_or_else(|| missing("namespace"))?;
595
596        if !(api.starts_with("http://") || api.starts_with("https://")) {
597            return Err(Failure::terminal(
598                "invalid_config",
599                "config api must use http:// or https://",
600            ));
601        }
602        split_repo(&repo)?;
603        checked_namespace(&namespace)?;
604        if !key_file.starts_with('/') {
605            return Err(Failure::terminal(
606                "invalid_config",
607                "config key_file must be an absolute path",
608            ));
609        }
610        let auth_file = string_field(value, "auth_file");
611        let auth_user = string_field(value, "auth_user");
612        if auth_file
613            .as_ref()
614            .is_some_and(|path| !path.starts_with('/'))
615        {
616            return Err(Failure::terminal(
617                "invalid_config",
618                "config auth_file must be an absolute path",
619            ));
620        }
621        if auth_user.is_some() && auth_file.is_none() {
622            return Err(Failure::terminal(
623                "invalid_config",
624                "config auth_user needs auth_file",
625            ));
626        }
627        let base_ref = string_field(value, "base_ref");
628        // A base ref naming another repository would provision this
629        // workspace from a history it has nothing to do with.
630        if let Some(base_ref) = &base_ref {
631            if !base_ref.starts_with(&format!("{repo}.git:refs/")) {
632                return Err(Failure::terminal(
633                    "invalid_config",
634                    "config base_ref must name this repository as owner/repo.git:refs/...",
635                ));
636            }
637        }
638        Ok(Self {
639            api,
640            repo,
641            owner,
642            key_file,
643            namespace,
644            base_ref,
645            auth_file,
646            auth_user,
647        })
648    }
649}
650
651/// One request from an orchestrator adapter.
652#[derive(Debug, Clone, PartialEq, Eq)]
653pub struct Request {
654    /// Which lifecycle step.
655    pub operation: Operation,
656    /// The binding this request is about.
657    pub identity: Identity,
658    /// Workspace directory, required by checkpoint.
659    pub workspace_path: Option<String>,
660    /// Exact base revision, when the caller pins one itself instead of
661    /// resolving `base_ref`.
662    pub base: Option<String>,
663}
664
665impl Request {
666    /// Parses a request against operator `config`.
667    ///
668    /// The scheme is named by the caller rather than inferred from which
669    /// fields are present. Inferring it would make a typo in a field
670    /// name silently select the other scheme, and the two derive
671    /// different change ids for the same work.
672    ///
673    /// # Errors
674    ///
675    /// Returns a [`Failure`] when the protocol version, operation, or
676    /// the fields the named scheme requires are missing or unusable.
677    pub fn parse(value: &serde_json::Value, config: &Config) -> Result<Self, Failure> {
678        if value
679            .get("protocol_version")
680            .and_then(serde_json::Value::as_u64)
681            != Some(PROTOCOL_VERSION)
682        {
683            return Err(Failure::terminal(
684                "invalid_request",
685                format!("request must be a protocol_version {PROTOCOL_VERSION} object"),
686            ));
687        }
688        let operation = Operation::parse(&string_field(value, "operation").ok_or_else(|| {
689            Failure::terminal("invalid_request", "request needs string operation")
690        })?)?;
691
692        let needs = |field: &str| {
693            Failure::terminal(
694                "invalid_request",
695                format!("this scheme needs string {field}"),
696            )
697        };
698        let identity = match string_field(value, "scheme").as_deref() {
699            Some("from-name") => Identity::from_name(
700                &config.namespace,
701                &config.repo,
702                &string_field(value, "workspace_name").ok_or_else(|| needs("workspace_name"))?,
703            )?,
704            Some("from-external") => Identity::from_external(
705                &config.namespace,
706                &config.repo,
707                &string_field(value, "workspace_key").ok_or_else(|| needs("workspace_key"))?,
708                &string_field(value, "external_id").ok_or_else(|| needs("external_id"))?,
709                &string_field(value, "generation").ok_or_else(|| needs("generation"))?,
710            )?,
711            _ => {
712                return Err(Failure::terminal(
713                    "invalid_request",
714                    "request needs scheme to be from-name or from-external",
715                ))
716            }
717        };
718
719        Ok(Self {
720            operation,
721            identity,
722            workspace_path: string_field(value, "workspace_path"),
723            base: string_field(value, "base"),
724        })
725    }
726}
727
728#[cfg(test)]
729mod tests {
730    use super::*;
731
732    fn config_json() -> serde_json::Value {
733        serde_json::json!({
734            "api": "http://127.0.0.1:9000",
735            "repo": "owner/repo",
736            "owner": "operator/agent",
737            "key_file": "/keys/owner.key",
738            "namespace": "sy",
739            "base_ref": "owner/repo.git:refs/heads/main",
740        })
741    }
742
743    fn config() -> Config {
744        Config::parse(&config_json()).expect("config parses")
745    }
746
747    fn identity(external: &str, generation: &str) -> Identity {
748        Identity::from_external("sy", "owner/repo", "issue-7", external, generation)
749            .expect("derives")
750    }
751
752    /// The property the whole scheme exists for: teardown is handed a
753    /// path and nothing else, so the binding must come back from the
754    /// name alone. If this ever stops holding, `WorktreeRemove` archives
755    /// the wrong change or none at all.
756    #[test]
757    fn a_from_name_binding_is_recoverable_from_the_path_alone() {
758        let created =
759            Identity::from_name("claude-code", "owner/repo", "cc-feature-abc123").expect("derives");
760        // Teardown's only inputs: the repo it is configured for, and the
761        // final component of the worktree path it was given.
762        let recovered =
763            Identity::from_name("claude-code", "owner/repo", "cc-feature-abc123").expect("derives");
764        assert_eq!(created, recovered);
765        assert_eq!(created.scheme, Scheme::FromName);
766        assert_eq!(
767            created.change_id,
768            "claude-code:owner/repo:cc-feature-abc123"
769        );
770        assert!(
771            created.fingerprint.is_empty(),
772            "a name-derived binding must not imply a fingerprint it cannot recover"
773        );
774    }
775
776    /// The converse, and the reason `FromExternal` needs durable state:
777    /// the name carries a truncated fingerprint, so the change id is not
778    /// recoverable from it. Stated as a test so nobody migrates an
779    /// adapter onto the wrong scheme expecting recovery to work.
780    #[test]
781    fn a_from_external_binding_is_not_recoverable_from_the_name() {
782        let derived = identity("ISSUE-7", "1");
783        assert!(
784            !derived.change_id.contains(&derived.workspace_name),
785            "the name would have carried the whole change id"
786        );
787        let truncated = derived.workspace_name.rsplit('-').next().expect("a suffix");
788        assert!(
789            truncated.len() < derived.fingerprint.len(),
790            "the name carries the full fingerprint, so durable state is not needed \
791             and this scheme has no cost to justify it"
792        );
793    }
794
795    /// Two schemes must not quietly produce the same ids for the same
796    /// inputs, or a migration between them would look like a no-op while
797    /// changing which change a workspace is bound to.
798    #[test]
799    fn the_two_schemes_do_not_collide() {
800        let named = Identity::from_name("sy", "owner/repo", "sy-issue-7").expect("derives");
801        let fingerprinted = identity("sy-issue-7", "1");
802        assert_ne!(named.change_id, fingerprinted.change_id);
803        assert_ne!(named.scheme, fingerprinted.scheme);
804    }
805
806    /// The scheme tag is inside the fingerprint, so changing the
807    /// derivation rules cannot leave ids untouched. Pinned because the
808    /// failure it prevents is silent: an adapter with durable state from
809    /// an older build would otherwise derive a second change for work
810    /// that already has one.
811    #[test]
812    fn the_scheme_tag_participates_in_the_fingerprint() {
813        let material_without_tag = {
814            let mut material = Vec::new();
815            for field in ["owner/repo", "ISSUE-7", "1"] {
816                material.extend_from_slice(field.len().to_string().as_bytes());
817                material.push(b':');
818                material.extend_from_slice(field.as_bytes());
819                material.push(b'\n');
820            }
821            ContentHash::blake3(&material).to_hex()
822        };
823        assert!(
824            !material_without_tag.contains(&identity("ISSUE-7", "1").fingerprint),
825            "the scheme tag is not covered, so a derivation change could keep the same ids"
826        );
827    }
828
829    #[test]
830    fn a_binding_is_stable_for_one_unit_of_work_and_attempt() {
831        let first = identity("ISSUE-7", "1");
832        let second = identity("ISSUE-7", "1");
833        assert_eq!(first, second, "derivation is not deterministic");
834        assert_eq!(
835            first.workspace_id,
836            format!("owner/repo/{}", first.workspace_name)
837        );
838        assert!(first.workspace_name.starts_with("sy-issue-7-"));
839        assert!(first.change_id.starts_with("sy:owner/repo:"));
840    }
841
842    /// A new attempt at the same work is a different change, and a
843    /// different unit of work is a different change. Both directions
844    /// matter: the first is what makes a retry a fresh workspace rather
845    /// than a collision, the second is the whole point of the id.
846    #[test]
847    fn a_different_unit_or_attempt_derives_a_different_binding() {
848        let base = identity("ISSUE-7", "1");
849        for (external, generation, why) in [
850            ("ISSUE-8", "1", "a different unit of work"),
851            ("ISSUE-7", "2", "a different attempt"),
852        ] {
853            let other = identity(external, generation);
854            assert_ne!(base.change_id, other.change_id, "{why} shared a change id");
855            assert_ne!(
856                base.workspace_name, other.workspace_name,
857                "{why} shared a workspace name"
858            );
859        }
860    }
861
862    /// The namespace is what stops two orchestrators driving one
863    /// repository from converging onto each other's change.
864    #[test]
865    fn the_namespace_separates_orchestrators() {
866        let symphony =
867            Identity::from_external("sy", "owner/repo", "k", "ISSUE-7", "1").expect("derives");
868        let claude =
869            Identity::from_external("cc", "owner/repo", "k", "ISSUE-7", "1").expect("derives");
870        assert_ne!(symphony.change_id, claude.change_id);
871        assert_ne!(symphony.workspace_name, claude.workspace_name);
872    }
873
874    /// Length-prefixing the fingerprint material. Without it, moving a
875    /// separator between two adjacent fields produces identical bytes
876    /// and therefore one change id for two different units of work.
877    #[test]
878    fn a_separator_cannot_be_moved_between_fingerprint_fields() {
879        let left = Identity::from_external("sy", "owner/repo", "k", "a:b", "c").expect("derives");
880        let right = Identity::from_external("sy", "owner/repo", "k", "a", "b:c").expect("derives");
881        assert_ne!(
882            left.fingerprint, right.fingerprint,
883            "a moved separator collided two distinct bindings"
884        );
885    }
886
887    #[test]
888    fn unsafe_or_oversized_identity_input_is_refused() {
889        let long = "x".repeat(MAX_IDENTIFIER + 1);
890        let cases = [
891            ("sy", "owner/repo", "../escape", "i", "1", "a traversal key"),
892            ("sy", "owner/repo", ".hidden", "i", "1", "a hidden key"),
893            ("sy", "owner/repo", "", "i", "1", "an empty key"),
894            ("sy", "owner", "k", "i", "1", "a one-segment repo"),
895            ("sy", "a/b/c", "k", "i", "1", "a three-segment repo"),
896            ("../sy", "owner/repo", "k", "i", "1", "an unsafe namespace"),
897            ("sy", "owner/repo", "k", "", "1", "an empty external id"),
898            (
899                "sy",
900                "owner/repo",
901                "k",
902                &long,
903                "1",
904                "an over-long external id",
905            ),
906            (
907                "sy",
908                "owner/repo",
909                "k",
910                "i",
911                &long,
912                "an over-long generation",
913            ),
914        ];
915        for (namespace, repo, key, external, generation, why) in cases {
916            assert!(
917                Identity::from_external(namespace, repo, key, external, generation).is_err(),
918                "accepted {why}"
919            );
920        }
921    }
922
923    #[test]
924    fn an_over_long_workspace_key_is_refused_but_a_long_one_is_truncated_in_the_name() {
925        let too_long = "k".repeat(MAX_WORKSPACE_KEY + 1);
926        assert!(Identity::from_external("sy", "owner/repo", &too_long, "i", "1").is_err());
927
928        let long = "k".repeat(MAX_WORKSPACE_KEY);
929        let derived =
930            Identity::from_external("sy", "owner/repo", &long, "i", "1").expect("derives");
931        // Truncated for the directory name, but the change id still
932        // separates two keys sharing that truncated prefix.
933        assert!(derived.workspace_name.len() < long.len() + KEY_PREFIX);
934        let sibling =
935            Identity::from_external("sy", "owner/repo", &long, "i", "2").expect("derives");
936        assert_ne!(derived.workspace_name, sibling.workspace_name);
937    }
938
939    #[test]
940    fn a_base_ref_resolves_only_to_a_real_git_object() {
941        let view = serde_json::json!({
942            "refs": {
943                "owner/repo.git:refs/heads/main": format!("11-{}", "a".repeat(40)),
944                "sha256": format!("12-{}", "b".repeat(64)),
945                "not-git": format!("1e-{}", "c".repeat(64)),
946                "short": "11-abc",
947                "not-hex": format!("11-{}", "z".repeat(40)),
948            }
949        });
950        assert_eq!(
951            base_from_view(&view, "owner/repo.git:refs/heads/main").expect("resolves"),
952            "a".repeat(40)
953        );
954        assert_eq!(
955            base_from_view(&view, "sha256").expect("resolves"),
956            "b".repeat(64)
957        );
958        for (name, why) in [
959            ("not-git", "a non-Git codec"),
960            ("short", "a truncated oid"),
961            ("not-hex", "a non-hex oid"),
962            ("absent", "a missing ref"),
963        ] {
964            let failure = base_from_view(&view, name).expect_err(why);
965            assert!(!failure.retryable, "{why} was reported as retryable");
966        }
967    }
968
969    #[test]
970    fn a_binding_check_refuses_a_receipt_for_another_change() {
971        let expected = identity("ISSUE-7", "1");
972        let good = serde_json::json!({
973            "workspace": expected.workspace_id,
974            "change_id": expected.change_id,
975        });
976        assert!(verify_binding(&good, &expected).is_ok());
977
978        let other = identity("ISSUE-8", "1");
979        for (response, why) in [
980            (
981                serde_json::json!({ "workspace": other.workspace_id, "change_id": expected.change_id }),
982                "another workspace",
983            ),
984            (
985                serde_json::json!({ "workspace": expected.workspace_id, "change_id": other.change_id }),
986                "another change",
987            ),
988            (
989                serde_json::json!({ "change_id": expected.change_id }),
990                "no workspace",
991            ),
992            (
993                serde_json::json!({ "workspace": expected.workspace_id }),
994                "no change",
995            ),
996            (
997                serde_json::json!({ "workspace": "", "change_id": expected.change_id }),
998                "an empty workspace",
999            ),
1000        ] {
1001            assert!(
1002                verify_binding(&response, &expected).is_err(),
1003                "accepted a receipt naming {why}"
1004            );
1005        }
1006    }
1007
1008    /// The retry decision is the field a scheduler branches on, so the
1009    /// terminal codes are pinned rather than left to a default.
1010    #[test]
1011    fn refusals_that_cannot_change_are_terminal_and_the_rest_are_not() {
1012        for code in [
1013            "workspace_state",
1014            "change_state",
1015            "stale_head",
1016            "unknown_key",
1017            "channel_not_owned",
1018            "identity_state",
1019            "bad_signature",
1020        ] {
1021            assert!(!is_retryable(code), "{code} would be retried forever");
1022        }
1023        for code in ["policy_unavailable", "log_evicted", "unclassified"] {
1024            assert!(
1025                is_retryable(code),
1026                "{code} stranded work that could succeed"
1027            );
1028        }
1029        // An unrecognised code is transient: a newer node is likelier
1030        // than a new permanent refusal, and failing fast is recoverable
1031        // while stranding is not.
1032        assert!(is_retryable("a_code_from_a_newer_node"));
1033    }
1034
1035    #[test]
1036    fn a_choir_rejection_keeps_its_code_and_a_broken_body_still_decides() {
1037        let typed = failure_from_response(
1038            r#"{"code":"workspace_state","detail":"binding differs"}"#,
1039            "creation",
1040        );
1041        assert_eq!(typed.code, "workspace_state");
1042        assert!(!typed.retryable);
1043        assert_eq!(typed.message, "binding differs");
1044
1045        let garbage = failure_from_response("<html>502</html>", "creation");
1046        assert_eq!(garbage.code, "choir_unavailable");
1047        assert!(garbage.retryable, "an unreadable body stranded the work");
1048        assert!(garbage.message.contains("creation"));
1049    }
1050
1051    /// Operator settings are not negotiable by the caller. Each of
1052    /// these would let a request reach a repository, a key, or a history
1053    /// the operator did not choose.
1054    #[test]
1055    fn configuration_refuses_what_would_redirect_the_lifecycle() {
1056        for (field, value, why) in [
1057            ("api", serde_json::json!("ftp://host"), "a non-HTTP scheme"),
1058            ("api", serde_json::json!(""), "an empty api"),
1059            ("repo", serde_json::json!("owner"), "a one-segment repo"),
1060            ("repo", serde_json::json!("a/b/c"), "a three-segment repo"),
1061            (
1062                "repo",
1063                serde_json::json!("../etc/passwd"),
1064                "a traversal repo",
1065            ),
1066            (
1067                "key_file",
1068                serde_json::json!("relative.key"),
1069                "a relative key path",
1070            ),
1071            (
1072                "namespace",
1073                serde_json::json!("../sy"),
1074                "an unsafe namespace",
1075            ),
1076            (
1077                "auth_file",
1078                serde_json::json!("relative"),
1079                "a relative auth file",
1080            ),
1081            (
1082                "base_ref",
1083                serde_json::json!("other/repo.git:refs/heads/main"),
1084                "a base ref naming another repository",
1085            ),
1086        ] {
1087            let mut raw = config_json();
1088            raw[field] = value;
1089            assert!(Config::parse(&raw).is_err(), "config accepted {why}");
1090        }
1091
1092        let mut raw = config_json();
1093        raw["auth_user"] = serde_json::json!("someone");
1094        assert!(
1095            Config::parse(&raw).is_err(),
1096            "config accepted a username with no credentials file"
1097        );
1098    }
1099
1100    /// The scheme is named, never inferred from which fields happen to
1101    /// be present. Inference would turn a typo into a silent switch
1102    /// between two rules that derive different change ids for one unit
1103    /// of work.
1104    #[test]
1105    fn the_scheme_is_named_rather_than_guessed_from_the_fields() {
1106        let config = config();
1107        let complete = serde_json::json!({
1108            "protocol_version": PROTOCOL_VERSION,
1109            "operation": "ensure",
1110            "workspace_key": "issue-7",
1111            "external_id": "ISSUE-7",
1112            "generation": "1",
1113            "workspace_name": "sy-issue-7",
1114        });
1115        // Every field for both schemes is present, and it still refuses.
1116        assert!(Request::parse(&complete, &config).is_err());
1117
1118        let mut named = complete.clone();
1119        named["scheme"] = serde_json::json!("from-name");
1120        let mut external = complete;
1121        external["scheme"] = serde_json::json!("from-external");
1122        let named = Request::parse(&named, &config).expect("from-name parses");
1123        let external = Request::parse(&external, &config).expect("from-external parses");
1124        assert_eq!(named.identity.scheme, Scheme::FromName);
1125        assert_eq!(external.identity.scheme, Scheme::FromExternal);
1126        assert_ne!(
1127            named.identity.change_id, external.identity.change_id,
1128            "the two schemes agreed, so naming one would not matter"
1129        );
1130    }
1131
1132    #[test]
1133    fn a_request_is_refused_without_its_version_operation_or_scheme_fields() {
1134        let config = config();
1135        let good = serde_json::json!({
1136            "protocol_version": PROTOCOL_VERSION,
1137            "operation": "ensure",
1138            "scheme": "from-name",
1139            "workspace_name": "sy-issue-7",
1140        });
1141        assert!(Request::parse(&good, &config).is_ok());
1142
1143        for (mutate, why) in [
1144            (
1145                serde_json::json!({"protocol_version": 2}),
1146                "a future protocol version",
1147            ),
1148            (
1149                serde_json::json!({"protocol_version": null}),
1150                "no protocol version",
1151            ),
1152            (
1153                serde_json::json!({"operation": "delete"}),
1154                "an unknown operation",
1155            ),
1156            (serde_json::json!({"operation": null}), "no operation"),
1157            (
1158                serde_json::json!({"scheme": "invented"}),
1159                "an unknown scheme",
1160            ),
1161            (
1162                serde_json::json!({"workspace_name": null}),
1163                "no workspace name",
1164            ),
1165            (
1166                serde_json::json!({"workspace_name": "../escape"}),
1167                "a traversal name",
1168            ),
1169        ] {
1170            let mut raw = good.clone();
1171            for (key, value) in mutate.as_object().expect("object") {
1172                raw[key] = value.clone();
1173            }
1174            assert!(Request::parse(&raw, &config).is_err(), "accepted {why}");
1175        }
1176    }
1177
1178    /// A from-external request carries its own required fields, and the
1179    /// binding it produces is the one the identity rules give.
1180    #[test]
1181    fn a_from_external_request_binds_what_its_fields_name() {
1182        let config = config();
1183        let raw = serde_json::json!({
1184            "protocol_version": PROTOCOL_VERSION,
1185            "operation": "checkpoint",
1186            "scheme": "from-external",
1187            "workspace_key": "issue-7",
1188            "external_id": "ISSUE-7",
1189            "generation": "1",
1190            "workspace_path": "/work/sy-issue-7",
1191        });
1192        let request = Request::parse(&raw, &config).expect("parses");
1193        assert_eq!(request.operation, Operation::Checkpoint);
1194        assert_eq!(request.workspace_path.as_deref(), Some("/work/sy-issue-7"));
1195        assert_eq!(
1196            request.identity,
1197            Identity::from_external("sy", "owner/repo", "issue-7", "ISSUE-7", "1")
1198                .expect("derives")
1199        );
1200
1201        for missing in ["workspace_key", "external_id", "generation"] {
1202            let mut raw = raw.clone();
1203            raw[missing] = serde_json::Value::Null;
1204            assert!(
1205                Request::parse(&raw, &config).is_err(),
1206                "accepted a from-external request with no {missing}"
1207            );
1208        }
1209    }
1210
1211    /// The reported base has to describe the workspace that exists, not
1212    /// the one this call asked for. They differ exactly when a retry
1213    /// resolves a ref that moved, which is the case an adapter is least
1214    /// able to notice on its own.
1215    #[test]
1216    fn a_reused_change_reports_the_base_it_is_bound_to_not_the_one_requested() {
1217        let requested = "a".repeat(40);
1218        let bound = "b".repeat(40);
1219        let reused = serde_json::json!({
1220            "reused": true,
1221            "base_revision": format!("11-{bound}"),
1222        });
1223        assert_eq!(bound_base(&reused, &requested), bound);
1224
1225        // A response that reports nothing usable must not invent one.
1226        for absent in [
1227            serde_json::json!({}),
1228            serde_json::json!({ "base_revision": "13-not-a-git-object" }),
1229            serde_json::json!({ "base_revision": format!("11-{}", "z".repeat(40)) }),
1230            serde_json::json!({ "base_revision": "11-abc" }),
1231        ] {
1232            assert_eq!(
1233                bound_base(&absent, &requested),
1234                requested,
1235                "an unusable base_revision was trusted: {absent}"
1236            );
1237        }
1238    }
1239
1240    #[test]
1241    fn a_failure_renders_the_wire_shape_an_adapter_forwards() {
1242        let json = Failure::terminal("binding_mismatch", "no").to_json();
1243        assert_eq!(json["protocol_version"], PROTOCOL_VERSION);
1244        assert_eq!(json["error"]["code"], "binding_mismatch");
1245        assert_eq!(json["error"]["retryable"], false);
1246    }
1247}