Skip to main content

salvor_server/
client_runs.rs

1//! The client-driven run surface: open or resume a run, read its log, and the
2//! generic guarded append for control and deterministic-context events.
3//!
4//! # Who owns the loop
5//!
6//! The server-driven endpoints in [`crate::runs`] own the loop: the server
7//! drives a run in a background task and the client submits data and reads
8//! events. This surface inverts that. The client (a browser folding the run's
9//! log in a wasm `ReplayCursor`, or an SDK) owns the loop and streams the
10//! events it produces; the server owns the durable log and, on every append,
11//! re-folds the log with the pure `salvor-replay` append-guard to confirm the
12//! incoming event is the one legal next event. The trust boundary is narrow and
13//! honest: the guard proves the run history is well formed (shape, correlation,
14//! ordering, terminal rules), which is all a log validator can prove.
15//!
16//! # Scope
17//!
18//! This surface carries only the control and deterministic-context events the
19//! client's cursor emits itself and that hold no secret and no side effect:
20//! `RunStarted`, `NowObserved`, `RandomObserved`, `Suspended`, `Resumed`,
21//! `SleepStarted`, `SleepCompleted`, `BudgetExceeded`, `RunCompleted`,
22//! `RunFailed`. The side-effecting steps (the model call and the tool call) are
23//! not supported here, so a model or tool event is refused with a clear error.
24//! Each has its own endpoint pair instead: [`model_step`] and [`tool_step`] for
25//! a call this server performs because it holds the key or the binary, and
26//! [`client_tool_intent`]/[`client_tool_completion`] and
27//! [`client_model_intent`]/[`client_model_completion`] for a call the CLIENT
28//! performs in its own process and reports back.
29//!
30//! # A client-driven run may sleep, and its client wakes it
31//!
32//! The durable-timer pair belongs on that list for the same reason the
33//! suspension pair does: both halves are recorded facts the client's own
34//! cursor produces, neither holds a secret, and neither has an effect outside
35//! the log. What differs is who ends the wait. Nothing in this process waits
36//! for a client-driven run's deadline: the wake sweeper skips every run a
37//! client holds a lease on, because re-driving one here would be a second
38//! writer racing the client's drive token for the same positions. So the
39//! client wakes its own run, the way the runtime does. On a later drive it
40//! replays its log, finds a `SleepStarted` with no `SleepCompleted` after it,
41//! compares the recorded `wake_at` against a clock reading it records as a
42//! `NowObserved`, and either stops (still asleep, nothing appended) or appends
43//! the `SleepCompleted` and carries on.
44//!
45//! This surface enforces only what it can see. The order of the pair is one
46//! such thing: a `SleepCompleted` may close only a sleep this log has open
47//! (see [`is_sleeping`]). The deadline itself is not, and deliberately so.
48//! `wake_at` is the client's own recorded instant and the clock that decides
49//! it has arrived is the client's; a server that re-judged it against its own
50//! clock would be making a determinism claim about a run it does not drive.
51//!
52//! # The single-writer lease
53//!
54//! Opening a run mints a per-run `drive_token`, required on every append. It is
55//! the per-run gate that layers on top of the process-wide bearer: one
56//! authenticated caller still cannot drive another caller's run, and a second
57//! live driver without the current lease is refused.
58//!
59//! A lease is held until it lapses. Re-opening a run whose lease is still
60//! current is refused with `409 lease_held` rather than handed a fresh token,
61//! because the caller re-opening is usually not the driver that already has the
62//! run: two app instances on one thread, a duplicated tab, a retrying
63//! middleware. Handing the newest caller the lease would put both of them to
64//! work on the same log, and the one that loses the race to a position dies on
65//! a divergence after having already run the step. The driver that holds the
66//! run keeps it until it goes quiet for the lease TTL or the run finishes; the
67//! refusal says how long that is, so the second caller waits instead of
68//! polling.
69//!
70//! Lapsing is the safety net, not the way a drive is meant to end. A driver
71//! that is finished says so with [`release`], and the run is another driver's
72//! on the very next request rather than a TTL later; a driver that will be busy
73//! for longer than the TTL, inside one tool body or one streamed model call,
74//! says THAT with [`heartbeat`], and keeps the run it never actually left.
75//! Without the first, a short-lived process locks the process that follows it
76//! out for a minute for nothing; without the second, a slow step loses a run
77//! its driver is still working on. Recording a dangling write by hand drops the
78//! lease as well, because a write nobody came back to record is a driver that
79//! is gone (see [`resolve`] and [`crate::runs::resolve`]).
80//!
81//! # The lease is process-lived; the run is not
82//!
83//! The lease registry is in memory and dies with the process, which is right
84//! for a lease: a token nobody is holding any more means nothing. What must
85//! not die with it is the fact that the run is client-driven at all, because
86//! every surface that must not become a second writer (this one when a run is
87//! re-opened, [`crate::runs::resume`], the wake sweeper) turns on that fact.
88//! So the run records it: [`append`] stamps `driven_by: client` on the
89//! `RunStarted` it accepts, and [`log_is_client_driven`] reads it back. A
90//! restarted server therefore re-opens a run its client is still driving,
91//! keeps refusing to resume it, and still leaves its timer alone, none of
92//! which it could do from memory it no longer has.
93//!
94//! # Labels on a client-driven run
95//!
96//! The client, not this server, synthesizes the run's `RunStarted` (see [`open`]):
97//! there is no server-side "creation" step here the way [`crate::runs::start`]'s
98//! `StartRequest` has one. So the correlation `labels` a caller wants land in the
99//! `RunStarted` payload the client builds and appends, and the one place this
100//! server ever inspects them is [`append`], the moment that event is accepted:
101//! the sanity bounds (see `salvor_runtime::validate_labels`) are checked there,
102//! against whatever `labels` the submitted event carries, before it is written.
103//!
104//! # `recorded_at` is stamped here, never trusted from the wire
105//!
106//! Every [`EventEnvelope`] carries a `recorded_at`. On the server-performed
107//! steps ([`model_step`], [`tool_step`], and their completions) it was always
108//! [`AppState::now`], because this server built those envelopes itself. This
109//! generic append is the one surface where the envelope arrives already built,
110//! by the client, and it is the one place `recorded_at` used to be taken on
111//! faith: a browser's clock is not this store's clock, and a run with an
112//! honest server-performed step next to a client-appended `RunStarted` stamped
113//! at the Unix epoch is a store that no longer tells the truth about when
114//! things happened. So [`append`] overwrites every incoming envelope's
115//! `recorded_at` with [`AppState::now`] before it is folded or written;
116//! whatever the client sent in that field is discarded. The event kind, its
117//! payload, and its `seq` are still exactly what the client submitted (those
118//! remain the client's fact, since the client is the one driving the run); only
119//! the "when was this durably recorded" stamp is the server's, uniformly,
120//! everywhere an envelope is written.
121
122use std::convert::Infallible;
123use std::time::Duration;
124
125use axum::Json;
126use axum::body::Bytes;
127use axum::extract::{Path, Query, State};
128use axum::http::header::ACCEPT;
129use axum::http::{HeaderMap, StatusCode};
130use axum::response::sse::{Event as SseEvent, KeepAlive, Sse};
131use axum::response::{IntoResponse, Response};
132use salvor_core::{
133    DedupOrigin, Effect, Event, EventEnvelope, LogValidator, Performer, RunId, RunStatus,
134    SequenceNumber, TokenUsage, derive_state,
135};
136use salvor_llm::{ContentDelta, MessageAccumulator, StreamEvent};
137use salvor_runtime::{
138    RuntimeError, ToolFailure, ToolFailureKind, encode_failure, hash_value, response_value,
139    usage_of, validate_against_schema, validate_labels,
140};
141use salvor_store::{CallClaim, CallClaimant};
142use salvor_tools::{ToolCtx, ToolOutcome};
143use serde::Deserialize;
144use serde_json::{Value, json};
145use time::format_description::well_known::Rfc3339;
146use tokio::sync::mpsc;
147use tokio_stream::wrappers::ReceiverStream;
148use uuid::Uuid;
149
150use crate::error::ApiError;
151use crate::executor::{ModelExecutor, ModelStream};
152use crate::state::{AppState, ClientRunLease, LeaseRelease};
153use std::sync::Arc;
154
155/// The header carrying the per-run drive token on a guarded append.
156const DRIVE_TOKEN_HEADER: &str = "x-drive-token";
157
158/// The largest event-append body this surface accepts, before parsing.
159const MAX_EVENTS_BODY: usize = 8 * 1024 * 1024;
160
161/// The most envelopes one append batch may carry, so a single request cannot
162/// grow a log without bound.
163const MAX_EVENTS_PER_BATCH: usize = 1024;
164
165/// The body of `POST /v1/client-runs`.
166#[derive(Debug, Deserialize)]
167struct OpenRequest {
168    /// The agent this run drives under (`agent_def_hash`). Informational:
169    /// the client records it inside the `RunStarted` event it appends.
170    #[serde(default)]
171    agent: Option<String>,
172    /// The run input. Informational, for the same reason as `agent`.
173    #[serde(default)]
174    input: Value,
175    /// An optional caller-chosen run id (a UUID). Minted when omitted.
176    #[serde(default)]
177    run_id: Option<String>,
178    /// Whether to record model request bodies on the intent (per-run, off by
179    /// default). Governs the server-performed model step, not yet implemented
180    /// on this surface.
181    #[serde(default)]
182    record_prompts: bool,
183}
184
185/// The body of `POST /v1/client-runs/{id}/events`.
186#[derive(Debug, Deserialize)]
187struct AppendRequest {
188    /// The envelopes to append, in order. Each is the pinned event-envelope
189    /// wire JSON already used by the event stream and `salvor history --json`.
190    events: Vec<EventEnvelope>,
191}
192
193/// The `?from_seq=` query on the log read.
194#[derive(Debug, Default, Deserialize)]
195pub struct LogQuery {
196    /// Return only envelopes at or after this sequence number.
197    #[serde(default)]
198    from_seq: Option<u64>,
199}
200
201/// The body of `POST /v1/client-runs/{id}/model-step`.
202#[derive(Debug, Deserialize)]
203struct ModelStepRequest {
204    /// The log position the client's cursor reserved for the model intent.
205    seq: u64,
206    /// The client's canonical model request value (a `MessageRequest` as JSON).
207    /// The server hashes and forwards exactly these bytes.
208    request: Value,
209}
210
211/// The `?stream=` query on the model step.
212#[derive(Debug, Default, Deserialize)]
213pub struct ModelStepQuery {
214    /// When `1` or `true`, stream provider events for a live ticker. The
215    /// `Accept: text/event-stream` header selects streaming too.
216    #[serde(default)]
217    stream: Option<String>,
218}
219
220/// The body of `POST /v1/client-runs/{id}/tool-step`.
221#[derive(Debug, Deserialize)]
222struct ToolStepRequest {
223    /// The log position the client's cursor reserved for the tool intent.
224    seq: u64,
225    /// The registered tool's name. Unknown to the registry is an error, and no
226    /// intent is written.
227    tool: String,
228    /// The typed input passed to the tool, recorded on the intent verbatim.
229    input: Value,
230    /// The idempotency key for this attempt, when the tool has one. The client
231    /// draws it from a recorded `RandomObserved` so it reproduces on replay.
232    #[serde(default)]
233    idempotency_key: Option<String>,
234    /// A client-declared effect, accepted for shape parity but deliberately
235    /// ignored: the recorded effect is the registry's operator-declared
236    /// one, so a caller cannot up- or down-grade it.
237    #[serde(default)]
238    #[allow(dead_code)]
239    effect: Option<Effect>,
240}
241
242/// The body of `POST /v1/client-runs/{id}/client-tool-intent`.
243///
244/// Notice what is NOT here, next to [`ToolStepRequest`]: no `effect` and no
245/// `idempotency_key`. Both come from the operator's declaration or from the
246/// server's own derivation, so there is no field for a caller to fill in.
247#[derive(Debug, Deserialize)]
248struct ClientToolIntentRequest {
249    /// The log position the client's cursor reserved for the tool intent.
250    seq: u64,
251    /// The declared client-performed tool's name. Undeclared is an error, and
252    /// no intent is written.
253    tool: String,
254    /// The input the client is about to perform the call with, checked against
255    /// the declared `input_schema` and then recorded on the intent verbatim.
256    input: Value,
257}
258
259/// The body of `POST /v1/client-runs/{id}/client-tool-completion`.
260///
261/// Two shapes, and exactly one of them: `output` for a call that returned a
262/// result, `error` for a call that did not. A body carrying both, or neither,
263/// is refused, because the two say opposite things about the same call and this
264/// server has no way to pick.
265#[derive(Debug, Deserialize)]
266struct ClientToolCompletionRequest {
267    /// The intent's position, which must be the pending intent at the log's end.
268    seq: u64,
269    /// What the client reports the call returned, checked against the declared
270    /// `output_schema` before it is recorded.
271    #[serde(default)]
272    output: Option<Value>,
273    /// What the client reports went wrong instead, when the call produced no
274    /// result at all.
275    #[serde(default)]
276    error: Option<ReportedFailure>,
277}
278
279/// A failure a client reports for a call it performed, the `error` half of
280/// [`ClientToolCompletionRequest`].
281///
282/// It carries what the client can honestly say and nothing more. `attempts` is
283/// not on the wire: it counts executions inside salvor's own retry loop, and
284/// there is no such loop here, so the recorded failure says one attempt rather
285/// than taking a number from a caller that could say anything.
286#[derive(Debug, Deserialize)]
287struct ReportedFailure {
288    /// The failure, in full. Recorded verbatim as the sentinel's `message`, the
289    /// same field a native tool's error chain lands in.
290    message: String,
291    /// Which dispatch layer failed, one of `invalid_input`, `handler`, or
292    /// `output_serialization`. Absent means `handler`, which is what a client
293    /// tool that ran and threw is: the layers either side of it are salvor's
294    /// own argument checking and result decoding, and neither exists for a call
295    /// salvor never dispatched.
296    #[serde(default)]
297    kind: Option<String>,
298}
299
300/// The body of `POST /v1/client-runs/{id}/client-model-intent`.
301///
302/// Notice what is NOT here, next to [`ModelStepRequest`]: no `request`. The
303/// server never sees the request, because it is not the one sending it; the
304/// client hashes its own request and reports the hash. Everything this struct
305/// carries is therefore the client's claim, which is exactly the trust posture
306/// a client-performed tool call already lives under.
307#[derive(Debug, Deserialize)]
308struct ClientModelIntentRequest {
309    /// The log position the client's cursor reserved for the model intent.
310    seq: u64,
311    /// The client's canonical hash of the request it is about to send. This is
312    /// the replay-correlation key, and salvor cannot recompute it: it never
313    /// holds the request. A client that hashes inconsistently diverges against
314    /// its own log and nobody else's.
315    request_hash: String,
316    /// The full request, recorded on the intent only when the run was opened
317    /// with `record_prompts: true`, exactly as on the server-performed step.
318    /// Informational: replay correlates on `request_hash` alone.
319    #[serde(default)]
320    request_body: Option<Value>,
321}
322
323/// The body of `POST /v1/client-runs/{id}/client-model-completion`.
324#[derive(Debug, Deserialize)]
325struct ClientModelCompletionRequest {
326    /// The intent's position, which must be the pending intent at the log's end.
327    seq: u64,
328    /// What the client reports the provider returned, recorded verbatim.
329    response: Value,
330    /// The token usage the client reports for the call, in the shape
331    /// [`Event::ModelCallCompleted`] records. Required, because it is what a
332    /// token budget counts, and a completion that quietly reported none would
333    /// under-count every budget the run is held to.
334    usage: TokenUsage,
335}
336
337/// The body of `POST /v1/client-runs/{id}/resolve`.
338#[derive(Debug, Deserialize)]
339struct ResolveRequest {
340    /// The output to record for the dangling write, verbatim, exactly as the
341    /// server-driven resolve takes it.
342    output: Value,
343}
344
345/// `POST /v1/client-runs`: open a fresh client-driven run, or re-open (resume)
346/// one whose log says it is client-driven.
347///
348/// A fresh run comes back with an empty log and a new drive token; the client
349/// appends its own `RunStarted` as the first event through the append endpoint.
350/// Re-opening a known client run returns its full recorded log and a fresh
351/// lease, for a refreshed tab to rebuild its cursor.
352///
353/// # A held lease is not taken away
354///
355/// A re-open only mints a fresh lease when nobody is driving the run: this
356/// process holds no lease for it (it never opened it, or it restarted), the
357/// lease it holds has lapsed because the driver went quiet for the TTL, or the
358/// run has finished and there is nothing left to drive. While a driver's lease
359/// is current, a re-open from anyone else is `409 lease_held`, carrying
360/// `details.lapses_in_seconds` so the caller knows when the hold expires.
361///
362/// The alternative, handing the run to whoever asked most recently, reads
363/// well for the one case it was written for (a tab the user refreshed, whose
364/// old driver is gone) and badly for every other: two app instances on one
365/// thread, a duplicated tab, a middleware that re-opens on a failed call. Each
366/// of those leaves two live drivers appending the same steps to one log, and
367/// the one that loses a position race takes a divergence after it has already
368/// done the work. A refreshed tab still resumes as before, because its old
369/// driver stopped presenting a token and its lease lapses.
370///
371/// A driver re-opening its OWN run, presenting its current token in the
372/// `X-Drive-Token` header, is allowed and keeps the lease it already has: it
373/// gets the recorded log back under the same token, which is what a client
374/// rebuilding its cursor after losing local state needs, and no second writer
375/// appears because the only writer is the one asking. No new token is minted,
376/// so a request already in flight under that token is not invalidated by the
377/// re-open. `record_prompts` on such a re-open is ignored; the lease keeps the
378/// setting it was opened with.
379///
380/// Two things say a run id is client-driven, and either is enough. The first is
381/// this process's own lease registry, which answers for every run opened since
382/// the server started. The second is the run's log: the `RunStarted` at its
383/// head carries `driven_by: client`, stamped by [`append`] when this server
384/// accepted it. The registry dies with the process and the log does not, so
385/// without the second an id opened before a restart would be refused as
386/// foreign, and a client-driven run would be stranded by any restart, which is
387/// the opposite of what a durable log is for. Adopting a run from its log mints
388/// a fresh lease exactly as re-opening one this process already held does; the
389/// client resumes by rebuilding its cursor from the returned log.
390///
391/// A chosen id whose history says nothing of the sort is a server-driven run,
392/// and it is still refused, so the two modes cannot collide over one store.
393pub async fn open(
394    State(state): State<AppState>,
395    headers: HeaderMap,
396    body: Bytes,
397) -> Result<impl IntoResponse, ApiError> {
398    let request: OpenRequest = parse_body(&body)?;
399    // `agent` and `input` are accepted but not enforced against the appended
400    // RunStarted; they matter once the server performs model calls.
401    let _ = (&request.agent, &request.input);
402
403    let run_id = match &request.run_id {
404        Some(text) => parse_run_id(text)?,
405        None => RunId::new(),
406    };
407
408    let log = state.store().read_log(run_id).await.map_err(store_error)?;
409
410    // A re-open: either this process opened the run (the lease registry knows
411    // it, which is also the only evidence a run opened but not yet started has)
412    // or its recorded log says it is client-driven (which survives the restart
413    // the registry does not). Return the recorded log, and a fresh lease unless
414    // a driver still holds one.
415    if state.is_client_run(run_id) || log_is_client_driven(&log) {
416        if let Some((held, remaining)) = state.current_client_lease(run_id)
417            && !run_is_finished(&log)
418        {
419            let presented = headers
420                .get(DRIVE_TOKEN_HEADER)
421                .and_then(|value| value.to_str().ok());
422            if presented != Some(held.drive_token.as_str()) {
423                return Err(lease_held(run_id, remaining));
424            }
425            // The holder re-opening its own run. It is the only writer either
426            // way, so nothing needs taking away: hand back the recorded log
427            // under the token it already has, and count the request as the
428            // proof of life it is.
429            state.touch_client_run(run_id);
430            return Ok((
431                StatusCode::OK,
432                Json(open_body(run_id, &held.drive_token, &log)),
433            ));
434        }
435        let drive_token = state.lease_client_run(run_id, request.record_prompts);
436        return Ok((StatusCode::OK, Json(open_body(run_id, &drive_token, &log))));
437    }
438
439    // A run id with existing history and no client-driven marker is foreign: a
440    // server-driven run, whose driver is this process's own. Refuse it rather
441    // than adopt it and become a second writer on its log.
442    if !log.is_empty() {
443        return Err(ApiError::RunExists(format!(
444            "run {} already has recorded history and its log does not record it as client-driven; \
445             it is a server-driven run, so it cannot be opened for client-driven runs",
446            run_id.as_uuid()
447        )));
448    }
449
450    let drive_token = state.lease_client_run(run_id, request.record_prompts);
451    Ok((
452        StatusCode::CREATED,
453        Json(open_body(run_id, &drive_token, &[])),
454    ))
455}
456
457/// `POST /v1/client-runs/{id}/release`: hand the lease back, so the next open
458/// takes the run at once instead of waiting out the TTL.
459///
460/// A driver that is finished for now says so with this rather than by going
461/// quiet. The lapse is the safety net for a driver that cannot say anything
462/// any more (it crashed, the tab closed); it is a poor way to end a drive that
463/// ended in an orderly fashion, because the run stays unopenable for the rest
464/// of the TTL. That is exactly what a short-lived process hits: an SDK invoke
465/// returns, the process exits, and the very next process is refused
466/// `409 lease_held` for up to a minute for no reason at all.
467///
468/// Only the lease goes. The log is untouched, and the run keeps its recorded
469/// `driven_by: client`, so it is still a client-driven run: a later open adopts
470/// it exactly as it adopts one after a restart, `POST /v1/runs/{id}/resume`
471/// still refuses it, and the wake sweeper still leaves its timer to its client,
472/// all three of which read that marker rather than this registry.
473///
474/// Idempotent: a run with no lease here (already released, lapsed, or never
475/// opened by this process) answers `200` with `released: false`. Nothing to
476/// give back is not an error, because the caller's goal, a run nobody is
477/// holding, is already true. Presenting a token that is not the current lease,
478/// or none at all, IS refused (`403 invalid_drive_token`), because that caller
479/// is asking to end somebody else's hold.
480pub async fn release(
481    State(state): State<AppState>,
482    Path(run_id_text): Path<String>,
483    headers: HeaderMap,
484) -> Result<Json<Value>, ApiError> {
485    let run_id = parse_run_id(&run_id_text)?;
486    let presented = headers
487        .get(DRIVE_TOKEN_HEADER)
488        .and_then(|value| value.to_str().ok());
489    match state.release_client_run(run_id, presented) {
490        LeaseRelease::Released => Ok(Json(json!({ "released": true }))),
491        LeaseRelease::NoLease => Ok(Json(json!({ "released": false }))),
492        // A missing token lands here alongside a wrong one, unlike the driving
493        // endpoints, which answer `401 missing_drive_token` for it. Here the
494        // question is not "did you bring credentials" but "is this hold
495        // yours to end", and the answer to that is no either way.
496        LeaseRelease::NotTheHolder => Err(ApiError::InvalidDriveToken(format!(
497            "the presented drive token is not the current lease for run {}, so it cannot release it",
498            run_id.as_uuid()
499        ))),
500    }
501}
502
503/// `POST /v1/client-runs/{id}/heartbeat`: refresh the lease without driving.
504/// Requires the `X-Drive-Token` header.
505///
506/// Presenting the drive token has always been the heartbeat, and every driving
507/// call carries it. What that misses is the driver that is busy for longer than
508/// the TTL between two calls: a tool that takes minutes, a model body streaming
509/// to the client's own screen. Nothing refreshes the lease while that runs, so
510/// it lapses mid-work and another opener can take the run out from under a
511/// driver that never went anywhere. So a driver with a long stretch of work
512/// ahead of it beats every so often instead.
513///
514/// The answer carries `lapses_in_seconds`, the whole TTL as of this beat, which
515/// is what a client needs to pick its interval without being told the server's
516/// configuration some other way.
517///
518/// A driver could get the same effect by re-opening the run under its own token
519/// (that keeps the lease and counts as proof of life), and that is what the
520/// SDKs did before this existed. It re-reads the whole recorded log every beat
521/// to do it, which is the wrong price for saying "still here".
522pub async fn heartbeat(
523    State(state): State<AppState>,
524    Path(run_id_text): Path<String>,
525    headers: HeaderMap,
526) -> Result<Json<Value>, ApiError> {
527    let run_id = parse_run_id(&run_id_text)?;
528    // The lease gate IS the beat: it checks the token is this run's current
529    // lease and refreshes `last_seen` on the way through, exactly as it does
530    // for an append.
531    authorize_drive(&state, run_id, &headers)?;
532    Ok(Json(
533        json!({ "lapses_in_seconds": whole_seconds(state.client_lease_ttl()) }),
534    ))
535}
536
537/// `GET /v1/client-runs/{id}/log`: the recorded envelopes, for cursor rebuild.
538///
539/// `?from_seq=<n>` returns only envelopes at or after `n`, so a resuming client
540/// that already holds a prefix fetches just the tail. The read needs no drive
541/// token (a second viewer may read), and it needs no lease either: a run whose
542/// driver released it (see [`release`]), whose lease lapsed, or that this
543/// process only knows from a log written before a restart is still a
544/// client-driven run's log, and this is a read, not a step in driving it. So
545/// the gate asks the same two questions [`open`] does for the same reason (see
546/// [`log_is_client_driven`]): this process's lease registry, or, failing that,
547/// the log's own `driven_by: client` marker on its `RunStarted`. A run neither
548/// says is client-driven, because it is server-driven or unknown outright,
549/// still answers `404 unknown_run`.
550pub async fn get_log(
551    State(state): State<AppState>,
552    Path(run_id_text): Path<String>,
553    Query(query): Query<LogQuery>,
554) -> Result<impl IntoResponse, ApiError> {
555    let run_id = parse_run_id(&run_id_text)?;
556    let mut log = state.store().read_log(run_id).await.map_err(store_error)?;
557    if !state.is_client_run(run_id) && !log_is_client_driven(&log) {
558        return Err(unknown_client_run(run_id));
559    }
560    if let Some(from) = query.from_seq {
561        log.retain(|env| env.seq.get() >= from);
562    }
563    Ok(Json(json!({ "log": log })))
564}
565
566/// `POST /v1/client-runs/{id}/events`: the generic guarded append.
567///
568/// Each envelope is re-folded through the `salvor-replay` append-guard against
569/// the run's current log. A byte-identical re-append at an existing position is
570/// a `200` no-op (a safe retry after a network blip); different bytes there, or
571/// an illegal next event, is a `409`. Model and tool events are refused: they
572/// belong to the server-performed model-step and tool-step endpoints, not to
573/// this generic append. A `SleepCompleted` that would close a sleep the log
574/// never started is a `409` too, the one pair-ordering rule this surface adds
575/// on top of the guard (see [`is_sleeping`]). The whole batch is validated
576/// before anything is written, so a batch that turns illegal appends nothing.
577///
578/// Every envelope's `recorded_at` is overwritten with [`AppState::now`] before
579/// it is folded or written (see the module docs): `recorded_at` is the store's
580/// fact, not the client's claim, so whatever a submitted envelope carries in
581/// that field is never trusted or stored.
582pub async fn append(
583    State(state): State<AppState>,
584    Path(run_id_text): Path<String>,
585    headers: HeaderMap,
586    body: Bytes,
587) -> Result<impl IntoResponse, ApiError> {
588    let run_id = parse_run_id(&run_id_text)?;
589
590    // The per-run lease gate.
591    authorize_drive(&state, run_id, &headers)?;
592
593    // Body-size discipline, as a fast precheck before parsing.
594    if body.len() > MAX_EVENTS_BODY {
595        return Err(ApiError::PayloadTooLarge(format!(
596            "append body is {} bytes, over the {MAX_EVENTS_BODY}-byte cap",
597            body.len()
598        )));
599    }
600    let request: AppendRequest = parse_body(&body)?;
601    if request.events.len() > MAX_EVENTS_PER_BATCH {
602        return Err(ApiError::PayloadTooLarge(format!(
603            "append batch carries {} events, over the {MAX_EVENTS_PER_BATCH} cap",
604            request.events.len()
605        )));
606    }
607
608    let stored = state.store().read_log(run_id).await.map_err(store_error)?;
609    let mut validator = LogValidator::new(stored);
610    let mut appended: Vec<u64> = Vec::with_capacity(request.events.len());
611    let mut to_append: Vec<EventEnvelope> = Vec::new();
612
613    for mut candidate in request.events {
614        if candidate.run_id != run_id {
615            return Err(ApiError::Divergence(format!(
616                "event names run {} but the path is run {}",
617                candidate.run_id.as_uuid(),
618                run_id.as_uuid()
619            )));
620        }
621        reject_side_effecting_kind(&candidate)?;
622        // The client synthesizes its own `RunStarted` (see the module docs);
623        // this append is the one place the server ever sees it, so it is
624        // where the sanity bounds on any carried `labels` are enforced. A
625        // byte-identical retry at an already-recorded position (handled just
626        // below) was validated the first time it landed, so re-checking here
627        // is cheap and harmless, never a behavior change.
628        //
629        // It is also where the run records who drives it. Reaching this line
630        // means the caller holds this run's lease, so the run IS client-driven,
631        // and the head of its log is the one place that fact can be written
632        // down durably. The server stamps it rather than trusting a submitted
633        // value, exactly as it does with `recorded_at` just below: what the
634        // client sent in the field is discarded, so a caller cannot mark a run
635        // client-driven anywhere but here, under a lease this server minted.
636        // Stamping before the retry comparison is what keeps a retry
637        // byte-identical: the resubmitted event is canonicalized the same way
638        // the recorded one was.
639        if let Event::RunStarted {
640            labels, driven_by, ..
641        } = &mut candidate.event
642        {
643            if let Some(labels) = labels {
644                validate_labels(labels).map_err(ApiError::BadRequest)?;
645            }
646            *driven_by = Some(Performer::Client);
647        }
648
649        let next_seq = validator.next_seq();
650        if candidate.seq < next_seq {
651            // An already-recorded position: idempotent retry or divergence.
652            // `recorded_at` is the store's fact, not the client's claim (see
653            // the module docs), so a retry's legality never turns on whatever
654            // timestamp this attempt happened to carry: canonicalize it to
655            // the already-recorded stamp before comparing the rest byte for
656            // byte.
657            let index = candidate.seq.get() as usize;
658            let recorded = &validator.log()[index];
659            candidate.recorded_at = recorded.recorded_at;
660            if *recorded == candidate {
661                appended.push(candidate.seq.get());
662                continue;
663            }
664            return Err(ApiError::Divergence(format!(
665                "different bytes submitted at the already-recorded seq {}",
666                candidate.seq.get()
667            )));
668        }
669
670        // The durable-timer pair's order is this surface's to check, because
671        // the shared append-guard deliberately does not (see [`is_sleeping`]).
672        // The working log, not the stored one, is what a batch carrying both
673        // halves at once must be judged against.
674        if matches!(candidate.event, Event::SleepCompleted {}) && !is_sleeping(validator.log()) {
675            return Err(ApiError::Divergence(format!(
676                "the SleepCompleted at seq {} would close a sleep this run has not started",
677                candidate.seq.get()
678            )));
679        }
680
681        // A new position: the server stamps its own clock reading, the same
682        // source every server-performed step uses, and ignores whatever
683        // `recorded_at` the client submitted. `recorded_at` is the store's
684        // fact, not the client's claim.
685        candidate.recorded_at = state.now();
686
687        // The append-guard decides legality.
688        validator
689            .push(candidate.clone())
690            .map_err(|error| ApiError::Divergence(error.to_string()))?;
691        appended.push(candidate.seq.get());
692        to_append.push(candidate);
693    }
694
695    // The batch validated end to end; commit the genuinely new events.
696    for envelope in &to_append {
697        state.store().append(envelope).await.map_err(append_error)?;
698    }
699
700    Ok((StatusCode::OK, Json(json!({ "appended": appended }))))
701}
702
703/// `POST /v1/client-runs/{id}/model-step`: the server-performed model call.
704///
705/// The client's cursor reserved `seq` as the model intent's position and hands
706/// the server the request to perform. The server recomputes `request_hash` from
707/// the body with the same canonical hash the runtime uses (so the client cannot
708/// lie about the hash), appends `ModelCallRequested` write-ahead, performs the
709/// call through the injected [`ModelExecutor`], appends `ModelCallCompleted`,
710/// and returns the completion. It mirrors `RunCtx::model_call` server-side.
711///
712/// Retry identity is `(seq, request_hash)`, mirroring `ReplayCursor::model_call`:
713///
714/// - A completed step already recorded at `seq` with the same hash returns the
715///   recorded completion; the provider is not called and the log does not grow.
716/// - A dangling intent at `seq` with the same hash (the tab died mid-call) is
717///   re-executed: an unanswered model request has no external effect to double,
718///   so the fresh completion correlates to the recorded intent.
719/// - A different hash at `seq`, or a non-model event there, is `409 divergence`.
720///
721/// With `Accept: text/event-stream` (or `?stream=1`) the provider's events
722/// stream as server-sent frames for a live ticker, and the assembled completion
723/// is recorded once at the end (byte-identical to the non-streaming path), so a
724/// tab that drops mid-stream leaves a dangling intent, re-issued safely.
725pub async fn model_step(
726    State(state): State<AppState>,
727    Path(run_id_text): Path<String>,
728    Query(query): Query<ModelStepQuery>,
729    headers: HeaderMap,
730    body: Bytes,
731) -> Result<Response, ApiError> {
732    let run_id = parse_run_id(&run_id_text)?;
733    let lease = authorize_drive(&state, run_id, &headers)?;
734
735    if body.len() > MAX_EVENTS_BODY {
736        return Err(ApiError::PayloadTooLarge(format!(
737            "model-step body is {} bytes, over the {MAX_EVENTS_BODY}-byte cap",
738            body.len()
739        )));
740    }
741    let ModelStepRequest { seq, request } = parse_body(&body)?;
742
743    // Recompute the hash from the submitted body with the runtime's own
744    // canonical hash: the hash the server records is the hash it will send.
745    let request_hash = hash_value(&request);
746    let log = state.store().read_log(run_id).await.map_err(store_error)?;
747    let plan = plan_model_step(&log, seq, &request_hash)?;
748    let streaming = wants_stream(&headers, &query);
749
750    match plan {
751        ModelStepPlan::Replay { response, usage } => {
752            // Already recorded: answer from the log, call nothing, grow nothing.
753            if streaming {
754                Ok(single_complete_stream(&response, usage))
755            } else {
756                Ok(completion_body(&response, usage).into_response())
757            }
758        }
759        ModelStepPlan::Perform { append_intent } => {
760            let executor = state.model_executor().ok_or_else(|| {
761                ApiError::ModelExecutorUnavailable(
762                    "this server has no model executor wired, so it cannot perform a model step"
763                        .to_owned(),
764                )
765            })?;
766
767            // Write-ahead: record the intent before the provider is contacted,
768            // so a crash mid-call leaves a dangling intent (re-issued on retry).
769            // A dangling-intent retry skips this: the intent is already recorded.
770            if append_intent {
771                let request_body = lease.record_prompts.then(|| request.clone());
772                let intent = EventEnvelope::new(
773                    run_id,
774                    SequenceNumber::new(seq),
775                    state.now(),
776                    Event::ModelCallRequested {
777                        seq: SequenceNumber::new(seq),
778                        request_hash: request_hash.clone(),
779                        request_body,
780                        // This server is about to make the call itself, so the
781                        // performer stays unrecorded: absent means salvor
782                        // witnessed it, which is what every model intent
783                        // written before the field existed meant.
784                        performed_by: None,
785                    },
786                );
787                let mut validator = LogValidator::new(log);
788                validator
789                    .push(intent.clone())
790                    .map_err(|error| ApiError::Divergence(error.to_string()))?;
791                state.store().append(&intent).await.map_err(append_error)?;
792            }
793
794            if streaming {
795                perform_streaming(state, run_id, seq, request, executor).await
796            } else {
797                perform_unary(&state, run_id, seq, request, executor.as_ref()).await
798            }
799        }
800    }
801}
802
803/// What a model step must do, decided from the recorded log alone.
804enum ModelStepPlan {
805    /// The step is already recorded: return this completion, execute nothing.
806    Replay {
807        /// The recorded response value.
808        response: Value,
809        /// The recorded token usage.
810        usage: TokenUsage,
811    },
812    /// The step must be performed. `append_intent` is true for a fresh call and
813    /// false for a dangling-intent re-issue (the intent is already recorded).
814    Perform {
815        /// Whether to write the intent before executing.
816        append_intent: bool,
817    },
818}
819
820/// Decides the model step from the log and the recomputed hash, mirroring
821/// `ReplayCursor::model_call`'s replay/re-issue/divergence branches.
822fn plan_model_step(
823    log: &[EventEnvelope],
824    seq: u64,
825    request_hash: &str,
826) -> Result<ModelStepPlan, ApiError> {
827    let next = log.len() as u64;
828    if seq == next {
829        // A fresh intent at the next contiguous position.
830        return Ok(ModelStepPlan::Perform {
831            append_intent: true,
832        });
833    }
834    if seq > next {
835        return Err(ApiError::Divergence(format!(
836            "model-step seq {seq} is beyond the log end {next}"
837        )));
838    }
839
840    // The position is already recorded: it must be the model intent, its hash
841    // must match, and its completion (if any) decides replay versus re-issue.
842    let recorded = &log[seq as usize];
843    let Event::ModelCallRequested {
844        request_hash: recorded_hash,
845        performed_by,
846        ..
847    } = &recorded.event
848    else {
849        return Err(ApiError::Divergence(format!(
850            "seq {seq} already holds a non-model event; it is not a model-step position"
851        )));
852    };
853    // A call the CLIENT performed is not this endpoint's to re-issue or to
854    // answer. Re-issuing it would let this server witness and record a response
855    // for an intent the log attributes to the client, smearing the one
856    // distinction `performed_by` exists to keep; and a cursor that asks this
857    // server to perform a step its own log says the client performed has
858    // genuinely diverged from that log. Close it with client-model-completion.
859    if *performed_by == Some(Performer::Client) {
860        return Err(ApiError::Divergence(format!(
861            "the model intent at seq {seq} was performed by the client, so this server may not \
862             perform or answer it; record its result with POST \
863             /v1/client-runs/{{id}}/client-model-completion"
864        )));
865    }
866    if recorded_hash != request_hash {
867        return Err(ApiError::Divergence(format!(
868            "model-step at seq {seq} carries a request hash that differs from the recorded intent"
869        )));
870    }
871    match log.get(seq as usize + 1) {
872        Some(next_env) => match &next_env.event {
873            Event::ModelCallCompleted {
874                seq: corr,
875                response,
876                usage,
877            } if corr.get() == seq => Ok(ModelStepPlan::Replay {
878                response: response.clone(),
879                usage: *usage,
880            }),
881            _ => Err(ApiError::Divergence(format!(
882                "the event after the intent at seq {seq} is not its completion"
883            ))),
884        },
885        // A dangling intent (the last event): re-issue the call.
886        None => Ok(ModelStepPlan::Perform {
887            append_intent: false,
888        }),
889    }
890}
891
892/// Performs a non-streaming model call: execute, record the completion, and
893/// return `{ response, usage }`.
894async fn perform_unary(
895    state: &AppState,
896    run_id: RunId,
897    seq: u64,
898    request: Value,
899    executor: &dyn ModelExecutor,
900) -> Result<Response, ApiError> {
901    let response = executor
902        .execute(request)
903        .await
904        .map_err(ApiError::ModelExecution)?;
905    let usage = usage_of(&response);
906    let response_value = response_value(&response);
907    append_completion(state, run_id, seq, &response_value, usage).await?;
908    Ok(completion_body(&response_value, usage).into_response())
909}
910
911/// Performs a streaming model call: open the provider stream, then hand a
912/// server-sent-events body a background task drives (ticker frames, then the
913/// recorded completion). Opening the stream synchronously means a failure to
914/// open is a proper error envelope, not a half-open stream.
915async fn perform_streaming(
916    state: AppState,
917    run_id: RunId,
918    seq: u64,
919    request: Value,
920    executor: Arc<dyn ModelExecutor>,
921) -> Result<Response, ApiError> {
922    let stream = executor
923        .open_stream(request)
924        .await
925        .map_err(ApiError::ModelExecution)?;
926    let (tx, rx) = mpsc::channel::<Result<SseEvent, Infallible>>(64);
927    tokio::spawn(drive_model_stream(state, run_id, seq, stream, tx));
928    Ok(Sse::new(ReceiverStream::new(rx))
929        .keep_alive(KeepAlive::default())
930        .into_response())
931}
932
933/// Pumps the provider stream: forward each event as a ticker frame and fold it
934/// into a [`MessageAccumulator`], then record the assembled completion once and
935/// send the final `complete` frame. A mid-stream error, an accumulation
936/// failure, or a completion-append failure sends an `error` frame and records
937/// nothing, so the write-ahead intent is left dangling and the run stays
938/// drivable.
939async fn drive_model_stream(
940    state: AppState,
941    run_id: RunId,
942    seq: u64,
943    mut stream: Box<dyn ModelStream>,
944    tx: mpsc::Sender<Result<SseEvent, Infallible>>,
945) {
946    let mut accumulator = MessageAccumulator::new();
947    loop {
948        match stream.next_event().await {
949            Some(Ok(event)) => {
950                if let Err(error) = accumulator.apply(&event) {
951                    let _ = tx.send(Ok(error_frame(&error.to_string()))).await;
952                    return;
953                }
954                if let Some(frame) = ticker_frame(&event)
955                    && tx
956                        .send(Ok(SseEvent::default()
957                            .event("delta")
958                            .data(frame.to_string())))
959                        .await
960                        .is_err()
961                {
962                    // The client hung up; stop, leaving the intent dangling.
963                    return;
964                }
965            }
966            Some(Err(message)) => {
967                let _ = tx.send(Ok(error_frame(&message))).await;
968                return;
969            }
970            None => break,
971        }
972    }
973
974    let response = match accumulator.into_message() {
975        Ok(response) => response,
976        Err(error) => {
977            let _ = tx.send(Ok(error_frame(&error.to_string()))).await;
978            return;
979        }
980    };
981    let usage = usage_of(&response);
982    let response_value = response_value(&response);
983    if append_completion(&state, run_id, seq, &response_value, usage)
984        .await
985        .is_err()
986    {
987        let _ = tx
988            .send(Ok(error_frame("recording the model completion failed")))
989            .await;
990        return;
991    }
992    let complete = completion_json(&response_value, usage);
993    let _ = tx
994        .send(Ok(SseEvent::default()
995            .event("complete")
996            .data(complete.to_string())))
997        .await;
998}
999
1000/// Records the `ModelCallCompleted` at `seq + 1`, correlated to the intent at
1001/// `seq`, after validating it is the legal next event.
1002async fn append_completion(
1003    state: &AppState,
1004    run_id: RunId,
1005    seq: u64,
1006    response: &Value,
1007    usage: TokenUsage,
1008) -> Result<(), ApiError> {
1009    let completion = EventEnvelope::new(
1010        run_id,
1011        SequenceNumber::new(seq + 1),
1012        state.now(),
1013        Event::ModelCallCompleted {
1014            seq: SequenceNumber::new(seq),
1015            response: response.clone(),
1016            usage,
1017        },
1018    );
1019    let log = state.store().read_log(run_id).await.map_err(store_error)?;
1020    let mut validator = LogValidator::new(log);
1021    validator
1022        .push(completion.clone())
1023        .map_err(|error| ApiError::Divergence(error.to_string()))?;
1024    state
1025        .store()
1026        .append(&completion)
1027        .await
1028        .map_err(append_error)
1029}
1030
1031/// Whether the request selects the streaming variant: `?stream=1`/`true`, or an
1032/// `Accept: text/event-stream` header.
1033fn wants_stream(headers: &HeaderMap, query: &ModelStepQuery) -> bool {
1034    if let Some(flag) = &query.stream
1035        && (flag == "1" || flag == "true")
1036    {
1037        return true;
1038    }
1039    headers
1040        .get(ACCEPT)
1041        .and_then(|value| value.to_str().ok())
1042        .is_some_and(|accept| accept.contains("text/event-stream"))
1043}
1044
1045/// The ticker frame for a provider event, or `None` for events with nothing a
1046/// live ticker shows (start/stop/ping). Text and thinking deltas and the final
1047/// usage are what a token/cost ticker consumes.
1048fn ticker_frame(event: &StreamEvent) -> Option<Value> {
1049    match event {
1050        StreamEvent::ContentBlockDelta { index, delta } => match delta {
1051            ContentDelta::Text { text } => {
1052                Some(json!({ "type": "text_delta", "index": index, "text": text }))
1053            }
1054            ContentDelta::Thinking { thinking } => {
1055                Some(json!({ "type": "thinking_delta", "index": index, "thinking": thinking }))
1056            }
1057            _ => None,
1058        },
1059        StreamEvent::MessageDelta { usage, .. } => {
1060            Some(json!({ "type": "usage", "output_tokens": usage.output_tokens }))
1061        }
1062        _ => None,
1063    }
1064}
1065
1066/// A one-frame server-sent-events body carrying an already-recorded completion,
1067/// for a streaming request that resolves to a replay (no live tokens).
1068fn single_complete_stream(response: &Value, usage: TokenUsage) -> Response {
1069    let frame = SseEvent::default()
1070        .event("complete")
1071        .data(completion_json(response, usage).to_string());
1072    Sse::new(tokio_stream::once(Ok::<_, Infallible>(frame)))
1073        .keep_alive(KeepAlive::default())
1074        .into_response()
1075}
1076
1077/// The `{ response, usage }` JSON both the non-streaming body and the `complete`
1078/// frame carry.
1079fn completion_json(response: &Value, usage: TokenUsage) -> Value {
1080    json!({ "response": response, "usage": usage })
1081}
1082
1083/// The non-streaming `200` body.
1084fn completion_body(response: &Value, usage: TokenUsage) -> Json<Value> {
1085    Json(completion_json(response, usage))
1086}
1087
1088/// An `error` server-sent-events frame carrying a human message.
1089fn error_frame(message: &str) -> SseEvent {
1090    SseEvent::default()
1091        .event("error")
1092        .data(json!({ "message": message }).to_string())
1093}
1094
1095/// The `201`/`200` open response body.
1096fn open_body(run_id: RunId, drive_token: &str, log: &[EventEnvelope]) -> Value {
1097    json!({
1098        "run": run_id.as_uuid().to_string(),
1099        "drive_token": drive_token,
1100        "log": log,
1101    })
1102}
1103
1104/// `POST /v1/client-runs/{id}/tool-step`: the server-performed tool call.
1105///
1106/// The client's cursor reserved `seq` as the tool intent's position. The server
1107/// looks the tool up in its injected [`ToolRegistry`](crate::ToolRegistry),
1108/// takes the operator-declared [`Effect`] from that registration (never from
1109/// the client, so a caller cannot up- or down-grade it), appends
1110/// `ToolCallRequested` write-ahead, dispatches the tool, appends
1111/// `ToolCallCompleted`, and returns the output. It mirrors `RunCtx::tool_call`
1112/// server-side, and its retry and reconciliation branches mirror
1113/// `ReplayCursor::tool_call`:
1114///
1115/// - A completed step recorded at `seq` with the same (tool, input, effect,
1116///   key) returns the recorded output; the tool is not dispatched and the log
1117///   does not grow.
1118/// - A dangling `Read`/`Idempotent` intent at `seq` (the tab died mid-call) is
1119///   re-executed under the RECORDED idempotency key, so an idempotent retry
1120///   reuses the exact key the provider collapses duplicates on.
1121/// - A dangling `Write` intent is `409 needs_reconciliation` carrying the
1122///   recorded intent as evidence, and nothing is dispatched: the write may have
1123///   landed, and only [`resolve`] may record its completion.
1124/// - A different (tool, input, effect, key) at `seq`, or a non-tool event
1125///   there, is `409 divergence`.
1126///
1127/// An unknown tool (or no registry at all) writes nothing, mirroring the model
1128/// step's no-executor rule: the step is retriable once the tool is registered.
1129pub async fn tool_step(
1130    State(state): State<AppState>,
1131    Path(run_id_text): Path<String>,
1132    headers: HeaderMap,
1133    body: Bytes,
1134) -> Result<Json<Value>, ApiError> {
1135    let run_id = parse_run_id(&run_id_text)?;
1136    authorize_drive(&state, run_id, &headers)?;
1137
1138    if body.len() > MAX_EVENTS_BODY {
1139        return Err(ApiError::PayloadTooLarge(format!(
1140            "tool-step body is {} bytes, over the {MAX_EVENTS_BODY}-byte cap",
1141            body.len()
1142        )));
1143    }
1144    let request: ToolStepRequest = parse_body(&body)?;
1145
1146    // Look the tool up before anything is written. No registry is a 503; a
1147    // registry without the named tool is a 404. Either way, nothing is written.
1148    let registry = state.tool_registry().ok_or_else(|| {
1149        ApiError::ToolRegistryUnavailable(
1150            "this server has no tool registry wired, so it cannot perform a tool step".to_owned(),
1151        )
1152    })?;
1153    let tool = registry.get(&request.tool).ok_or_else(|| {
1154        ApiError::UnknownTool(format!(
1155            "no tool named `{}` is registered on this server",
1156            request.tool
1157        ))
1158    })?;
1159
1160    // The effect is the registry's operator declaration, never the client's.
1161    // The client-declared `effect` field on the body is dropped here.
1162    let effect = tool.effect();
1163    let ToolStepRequest {
1164        seq,
1165        tool: tool_name,
1166        input,
1167        idempotency_key,
1168        effect: _,
1169    } = request;
1170
1171    let log = state.store().read_log(run_id).await.map_err(store_error)?;
1172    let plan = plan_tool_step(
1173        &log,
1174        seq,
1175        &tool_name,
1176        &input,
1177        effect,
1178        idempotency_key.as_deref(),
1179    )?;
1180
1181    match plan {
1182        ToolStepPlan::Replay { output } => Ok(tool_output_body(&output)),
1183        ToolStepPlan::Reconcile { intent } => Err(ApiError::NeedsReconciliation {
1184            message: format!(
1185                "run {} needs reconciliation: a write was recorded but never completed, so it \
1186                 may or may not have taken effect. Verify externally, then resolve it",
1187                run_id.as_uuid()
1188            ),
1189            intent,
1190        }),
1191        ToolStepPlan::Perform {
1192            append_intent,
1193            exec_key,
1194        } => {
1195            // Write-ahead: record the intent before the tool runs, so a crash
1196            // mid-call leaves a dangling intent (re-issued or reconciled on
1197            // retry, per effect). A dangling re-issue skips this: the intent is
1198            // already recorded.
1199            if append_intent {
1200                let intent = EventEnvelope::new(
1201                    run_id,
1202                    SequenceNumber::new(seq),
1203                    state.now(),
1204                    Event::ToolCallRequested {
1205                        seq: SequenceNumber::new(seq),
1206                        tool: tool_name.clone(),
1207                        input: input.clone(),
1208                        effect,
1209                        idempotency_key: exec_key.clone(),
1210                        performed_by: None,
1211                    },
1212                );
1213                let mut validator = LogValidator::new(log);
1214                validator
1215                    .push(intent.clone())
1216                    .map_err(|error| ApiError::Divergence(error.to_string()))?;
1217                state.store().append(&intent).await.map_err(append_error)?;
1218            }
1219
1220            // Dispatch through the same erased contract the runtime uses, with
1221            // the idempotency key on the context so an idempotent retry reuses
1222            // it. A dispatch failure is an error envelope with no completion, so
1223            // the intent is left dangling (legal, the crash story).
1224            let ctx = ToolCtx::new(exec_key);
1225            let outcome = tool
1226                .call_json(&ctx, input)
1227                .await
1228                .map_err(|error| ApiError::ToolExecution(error.to_string()))?;
1229            let output = match outcome {
1230                ToolOutcome::Output(value) => value,
1231                ToolOutcome::Suspend(_) => {
1232                    return Err(ApiError::ToolExecution(format!(
1233                        "tool `{tool_name}` suspended, which a server-performed tool step does \
1234                         not support; no completion recorded"
1235                    )));
1236                }
1237                // Refused for the same reason a suspension is: a step endpoint
1238                // performs one call and answers with its output. Parking the
1239                // run belongs to a driver, and the client owns the loop here.
1240                ToolOutcome::Sleep(_) => {
1241                    return Err(ApiError::ToolExecution(format!(
1242                        "tool `{tool_name}` asked to sleep, which a server-performed tool step \
1243                         does not support; no completion recorded"
1244                    )));
1245                }
1246            };
1247            append_tool_completion(&state, run_id, seq, &output, None).await?;
1248            Ok(tool_output_body(&output))
1249        }
1250    }
1251}
1252
1253/// What a tool step must do, decided from the recorded log and the registry's
1254/// effect alone.
1255enum ToolStepPlan {
1256    /// The step is already recorded: return this output, dispatch nothing.
1257    Replay {
1258        /// The recorded tool output.
1259        output: Value,
1260    },
1261    /// A dangling write: surface reconciliation with this intent evidence,
1262    /// dispatch nothing.
1263    Reconcile {
1264        /// The recorded write intent, for the error body.
1265        intent: Value,
1266    },
1267    /// The step must be performed. `append_intent` is true for a fresh call and
1268    /// false for a dangling re-issue (the intent is already recorded); `exec_key`
1269    /// is the idempotency key to dispatch under (the recorded key on a re-issue).
1270    Perform {
1271        /// Whether to write the intent before dispatching.
1272        append_intent: bool,
1273        /// The idempotency key handed to the tool for this attempt.
1274        exec_key: Option<String>,
1275    },
1276}
1277
1278/// Decides the tool step from the log and the registry's effect, mirroring
1279/// `ReplayCursor::tool_call`'s replay, re-issue, reconciliation, and divergence
1280/// branches. The effect is the registry's, so a client cannot change it.
1281fn plan_tool_step(
1282    log: &[EventEnvelope],
1283    seq: u64,
1284    tool: &str,
1285    input: &Value,
1286    effect: Effect,
1287    idempotency_key: Option<&str>,
1288) -> Result<ToolStepPlan, ApiError> {
1289    let next = log.len() as u64;
1290    if seq == next {
1291        // A fresh intent at the next contiguous position.
1292        return Ok(ToolStepPlan::Perform {
1293            append_intent: true,
1294            exec_key: idempotency_key.map(ToOwned::to_owned),
1295        });
1296    }
1297    if seq > next {
1298        return Err(ApiError::Divergence(format!(
1299            "tool-step seq {seq} is beyond the log end {next}"
1300        )));
1301    }
1302
1303    // The position is already recorded: it must be the tool intent, and its
1304    // (tool, input, effect, key) must all match, exactly as the cursor checks.
1305    let recorded = &log[seq as usize];
1306    let Event::ToolCallRequested {
1307        tool: recorded_tool,
1308        input: recorded_input,
1309        effect: recorded_effect,
1310        idempotency_key: recorded_key,
1311        ..
1312    } = &recorded.event
1313    else {
1314        return Err(ApiError::Divergence(format!(
1315            "seq {seq} already holds a non-tool event; it is not a tool-step position"
1316        )));
1317    };
1318    if recorded_tool != tool
1319        || recorded_input != input
1320        || *recorded_effect != effect
1321        || recorded_key.as_deref() != idempotency_key
1322    {
1323        return Err(ApiError::Divergence(format!(
1324            "tool-step at seq {seq} diverges from the recorded intent (tool, input, effect, or key)"
1325        )));
1326    }
1327    match log.get(seq as usize + 1) {
1328        Some(next_env) => match &next_env.event {
1329            Event::ToolCallCompleted {
1330                seq: corr, output, ..
1331            } if corr.get() == seq => Ok(ToolStepPlan::Replay {
1332                output: output.clone(),
1333            }),
1334            _ => Err(ApiError::Divergence(format!(
1335                "the event after the intent at seq {seq} is not its completion"
1336            ))),
1337        },
1338        // A dangling intent (the last event): the effect decides. Write never
1339        // re-executes; Read/Idempotent re-execute under the RECORDED key.
1340        None => match effect {
1341            Effect::Write => Ok(ToolStepPlan::Reconcile {
1342                intent: intent_evidence(recorded),
1343            }),
1344            Effect::Read | Effect::Idempotent => Ok(ToolStepPlan::Perform {
1345                append_intent: false,
1346                exec_key: recorded_key.clone(),
1347            }),
1348        },
1349    }
1350}
1351
1352/// The reconciliation evidence carried in a `needs_reconciliation` error body:
1353/// the recorded write intent plus when it was recorded, mirroring the
1354/// server-driven resolve's `reconcile_intent` and `json::pending` shapes.
1355fn intent_evidence(envelope: &EventEnvelope) -> Value {
1356    let Event::ToolCallRequested {
1357        seq,
1358        tool,
1359        input,
1360        effect,
1361        idempotency_key,
1362        ..
1363    } = &envelope.event
1364    else {
1365        return Value::Null;
1366    };
1367    json!({
1368        "kind": "tool",
1369        "seq": seq.get(),
1370        "tool": tool,
1371        "input": input,
1372        "effect": effect,
1373        "idempotency_key": idempotency_key,
1374        "recorded_at": envelope.recorded_at.format(&Rfc3339).unwrap_or_default(),
1375    })
1376}
1377
1378/// Records the `ToolCallCompleted` at `seq + 1`, correlated to the intent at
1379/// `seq`, after validating it is the legal next event.
1380///
1381/// `deduplicated_from` names the completion this output was copied from, on the
1382/// one path that copies one (a repeated call under a declared idempotency key);
1383/// every other caller passes `None`, and the recorded bytes are then exactly
1384/// what this helper wrote before the field existed.
1385///
1386/// `settled_by` is never stamped here. This is the run recording what it was
1387/// told; only `Runtime::resolve`, where a person records a completion over the
1388/// run's head, names a settler.
1389///
1390/// # It settles the store's claim, when this call holds one
1391///
1392/// A call opened under a DECLARED idempotency key claimed that identity in the
1393/// store before its intent was written (see [`client_tool_intent`]). The
1394/// completion has to release it, in the same atomic step the event is appended,
1395/// or the store would go on saying the call is in flight while its result sits
1396/// recorded, and every later call under that key would be refused forever with
1397/// nothing anywhere to say why. This is the same reconciliation
1398/// `Runtime::resolve` performs for the hand-recorded path, and it is a no-op
1399/// for a positional key, which claims nothing.
1400async fn append_tool_completion(
1401    state: &AppState,
1402    run_id: RunId,
1403    seq: u64,
1404    output: &Value,
1405    deduplicated_from: Option<DedupOrigin>,
1406) -> Result<(), ApiError> {
1407    let completion = EventEnvelope::new(
1408        run_id,
1409        SequenceNumber::new(seq + 1),
1410        state.now(),
1411        Event::ToolCallCompleted {
1412            seq: SequenceNumber::new(seq),
1413            output: output.clone(),
1414            deduplicated_from,
1415            settled_by: None,
1416        },
1417    );
1418    let log = state.store().read_log(run_id).await.map_err(store_error)?;
1419    let held = held_claim(state, &log, run_id, seq).await?;
1420    let mut validator = LogValidator::new(log);
1421    validator
1422        .push(completion.clone())
1423        .map_err(|error| ApiError::Divergence(error.to_string()))?;
1424    match held {
1425        Some(key) => state
1426            .store()
1427            .append_settling_call(
1428                &completion,
1429                CallClaimant {
1430                    tool: &key.0,
1431                    idempotency_key: &key.1,
1432                    run_id,
1433                    intent_seq: SequenceNumber::new(seq),
1434                },
1435            )
1436            .await
1437            .map_err(append_error),
1438        None => state
1439            .store()
1440            .append(&completion)
1441            .await
1442            .map_err(append_error),
1443    }
1444}
1445
1446/// The `(tool, idempotency key)` this run's intent at `seq` holds an unsettled
1447/// claim on, if it holds one at all.
1448///
1449/// Mirrors the lookup `Runtime::resolve` does before it settles: a commitment
1450/// that names this exact run and this exact intent, and is not already settled,
1451/// is one this completion owns and must close. Anything else (no commitment,
1452/// somebody else's, one already settled) is left alone, because settling a
1453/// commitment one does not own is refused by the store and would be a bug here
1454/// rather than a race.
1455async fn held_claim(
1456    state: &AppState,
1457    log: &[EventEnvelope],
1458    run_id: RunId,
1459    seq: u64,
1460) -> Result<Option<(String, String)>, ApiError> {
1461    let Some(EventEnvelope {
1462        event:
1463            Event::ToolCallRequested {
1464                tool,
1465                idempotency_key: Some(key),
1466                ..
1467            },
1468        ..
1469    }) = log.get(seq as usize)
1470    else {
1471        return Ok(None);
1472    };
1473    let commitment = state
1474        .store()
1475        .lookup_call(tool, key)
1476        .await
1477        .map_err(store_error)?;
1478    let ours = commitment.is_some_and(|commitment| {
1479        commitment.run_id == run_id
1480            && commitment.intent_seq.get() == seq
1481            && commitment.completion_seq.is_none()
1482    });
1483    Ok(ours.then(|| (tool.clone(), key.clone())))
1484}
1485
1486/// The `200` tool-step body, `{ "output": <json> }`.
1487fn tool_output_body(output: &Value) -> Json<Value> {
1488    Json(json!({ "output": output }))
1489}
1490
1491/// `POST /v1/client-runs/{id}/resolve`: record a dangling write's completion by
1492/// hand for a client-driven run, the drive-token-gated twin of the
1493/// server-driven `POST /v1/runs/{id}/resolve`.
1494///
1495/// State-validated exactly like the server-driven resolve: it is legal only
1496/// when the run's log ends at a dangling `Write` intent, it correlates the
1497/// caller-supplied output to that intent, and it dispatches nothing. It reuses
1498/// the same `Runtime::resolve` the server-driven endpoint does, so the two
1499/// share one reconciliation contract. After it records the completion the run
1500/// is drivable again, so the client re-fetches the log and its cursor sails
1501/// past the once-dangling intent.
1502///
1503/// # The lease a resolve clears, and the one it does not
1504///
1505/// A dangling write means the driver that opened it never came back to record
1506/// what happened, so a resolve is normally the sign that that driver is gone
1507/// and the lease it left behind is holding the run for nobody. Both resolve
1508/// endpoints say so the same way (see
1509/// [`AppState::clear_client_lease`](crate::state::AppState::clear_client_lease)):
1510/// the run's lease is dropped once the resolution is recorded, and the next
1511/// open takes the run at once instead of waiting out the TTL. The operator's
1512/// path, `POST /v1/runs/{id}/resolve`, is where that matters, because the
1513/// caller there presents no token and is by definition not the driver.
1514///
1515/// This endpoint is the exception, and passes its own token to be kept. Getting
1516/// in here at all means presenting the run's current lease, which is the driver
1517/// saying it is right here; revoking it would strand the very caller that just
1518/// proved it is alive, mid-run, over a write it is about to carry on past.
1519pub async fn resolve(
1520    State(state): State<AppState>,
1521    Path(run_id_text): Path<String>,
1522    headers: HeaderMap,
1523    body: Bytes,
1524) -> Result<Json<Value>, ApiError> {
1525    let run_id = parse_run_id(&run_id_text)?;
1526    let lease = authorize_drive(&state, run_id, &headers)?;
1527    let request: ResolveRequest = parse_body(&body)?;
1528
1529    // The same declaration check the operator's resolve endpoint makes, through
1530    // the same helper, so a hand-recorded output meets one set of rules however
1531    // it arrives. Salvor witnessed neither the call nor the resolution, and the
1532    // declaration is the only thing that says what a finished call looks like.
1533    let log = state.store().read_log(run_id).await.map_err(store_error)?;
1534    crate::client_tools::check_client_resolution(&state.client_tools(), &log, &request.output)?;
1535
1536    match state.runtime().resolve(run_id, request.output).await {
1537        Ok(_) => {
1538            // The resolve rule, with this caller's own lease held back from
1539            // it. In the one case where the stored lease is no longer the one
1540            // authorized above (it lapsed while the completion was being
1541            // written and another driver took the run), this does clear that
1542            // driver's lease, which is the same outcome it would meet on its
1543            // next call anyway.
1544            state.clear_client_lease(run_id, Some(&lease.drive_token));
1545            Ok(Json(json!({
1546                "run": run_id.as_uuid().to_string(),
1547                "resolved": true,
1548            })))
1549        }
1550        Err(RuntimeError::NotReconcilable { status, .. }) => {
1551            // Always a client-driven run: getting in here meant presenting its
1552            // drive token.
1553            Err(resolve_refusal(run_id, &log, &status, true))
1554        }
1555        Err(error) => Err(ApiError::Internal(error.to_string())),
1556    }
1557}
1558
1559/// The `409 wrong_state` a resolve answers when the run has no dangling write
1560/// to settle, shared by both resolve endpoints.
1561///
1562/// # Why a client-driven run gets its own sentence
1563///
1564/// The generic form quotes the runtime's status name, and two of those names
1565/// end in "use recover". `recover` is a server-driven verb: it spawns a driver
1566/// task over the run, which is exactly what must never happen to a run whose
1567/// client holds the single-writer lease, and `POST /v1/runs/{id}/resume`
1568/// refuses such a run for that reason. Telling an operator to reach for it is
1569/// telling them to do the one thing the next endpoint will refuse.
1570///
1571/// What a client-driven run's unfinished call actually needs is either nothing
1572/// or a resolve, and which one is a fact about the call, so the message says
1573/// it: an unfinished read or model call is re-performed by the client on its
1574/// next drive, with no operator action at all, while a dangling write is the
1575/// one thing this endpoint settles (and a run holding one never reaches this
1576/// refusal, because it folds to `needs_reconciliation` and the resolve
1577/// succeeds).
1578pub(crate) fn resolve_refusal(
1579    run_id: RunId,
1580    log: &[EventEnvelope],
1581    status: &str,
1582    client_driven: bool,
1583) -> ApiError {
1584    if client_driven && let Some(unfinished) = unfinished_call_sentence(log) {
1585        return ApiError::WrongState(format!(
1586            "run {} has no dangling write to settle: {unfinished}. A write recorded with no \
1587             completion after it is the one thing this endpoint records by hand",
1588            run_id.as_uuid()
1589        ));
1590    }
1591    ApiError::WrongState(format!(
1592        "run {} does not need reconciliation (status: {status}); there is no dangling write to \
1593         resolve",
1594        run_id.as_uuid()
1595    ))
1596}
1597
1598/// How a client-driven run's unfinished call reads in a refusal, or `None` when
1599/// the log does not end at one (the run finished, parked, or never started, all
1600/// of which the status name already describes honestly).
1601fn unfinished_call_sentence(log: &[EventEnvelope]) -> Option<String> {
1602    match &log.last()?.event {
1603        Event::ToolCallRequested {
1604            seq, tool, effect, ..
1605        } if !matches!(effect, Effect::Write) => Some(format!(
1606            "its log ends at an unfinished {effect:?} call to `{tool}` at seq {}, which the client \
1607             performs again on its next drive rather than by being resolved",
1608            seq.get()
1609        )),
1610        Event::ModelCallRequested { seq, .. } => Some(format!(
1611            "its log ends at an unfinished model call at seq {}, which the client performs again \
1612             on its next drive rather than by being resolved",
1613            seq.get()
1614        )),
1615        _ => None,
1616    }
1617}
1618
1619/// The idempotency key a CLIENT-performed tool call presents: derived by this
1620/// server from where the call sits in the run (run id, sequence, tool name),
1621/// never supplied by the caller.
1622///
1623/// This deliberately differs from the server-performed [`tool_step`], where the
1624/// client supplies `idempotency_key` on the request body and the server records
1625/// what it was given. The difference is not an oversight, and the older
1626/// endpoint should not be "fixed" to match.
1627///
1628/// There, salvor performs the call. The party choosing the key is not the party
1629/// making the write, and a key chosen badly costs the caller nothing but its own
1630/// retry failing to collapse. Here the client both chooses the key and performs
1631/// the write, in a process salvor never sees. That is the one case where the
1632/// party choosing the key is also the party who benefits from a duplicate
1633/// landing: a client that wants to be paid twice supplies a fresh key for the
1634/// second attempt and the provider, seeing two distinct calls, honors both,
1635/// while salvor's log shows two honest-looking intents. Deriving the key removes
1636/// the choice. The same (run, seq, tool) always derives the same key, so an
1637/// honest retry after a dropped response presents the identical key the first
1638/// attempt did and the provider collapses the pair, and a second attempt cannot
1639/// present a different one.
1640///
1641/// Shaped after `salvor_engine`'s `fork_safe_idempotency_key`: a canonical hash
1642/// of a small JSON object, using the same `hash_value` the rest of the workspace
1643/// hashes with, so the key is reproducible across processes and languages and a
1644/// client can derive it independently to check the server's work.
1645///
1646/// # What the hash is over, and who chooses
1647///
1648/// The client never chooses, on either shape. What the OPERATOR chooses, in the
1649/// declaration's [`idempotency_key`](crate::client_tools::ClientToolDecl::idempotency_key),
1650/// is what the hash is over:
1651///
1652/// - **No fields declared: `{ run, seq, tool }`.** The call's position in the
1653///   run. This is an attempt identifier and promises exactly one thing: the
1654///   same position, retried, presents the same key. Two calls at two positions
1655///   are two calls, however alike their arguments.
1656/// - **Fields declared: `{ run, tool, <field>: <value>, ... }`.** The call's
1657///   content. `seq` is deliberately absent, which is the whole difference: the
1658///   same refund asked for twice in one run derives one key both times, so the
1659///   second is the same call rather than a second refund. Each value is the
1660///   intent's own recorded input, so the key is a fact about what was
1661///   authorized. The order the operator wrote the names in does not change the
1662///   key: `hash_value` canonicalizes, which sorts object keys, so a
1663///   reordered declaration derives the identical hash and a client deriving it
1664///   independently need not mirror the file's ordering.
1665///
1666/// The run id is on both shapes, so a declared key is an identity within one
1667/// run and never collides with another run's. That is deliberate. Only a tool
1668/// can honestly say two calls in different runs are the same effect, and a
1669/// declaration is the operator's word about a tool this server holds no code
1670/// for; scoping the identity to the run keeps the claim to something the
1671/// operator can actually know.
1672///
1673/// # Errors
1674///
1675/// [`ApiError::BadRequest`] naming the field when a declared key field is
1676/// absent from the input. The load-time rule makes every key field required by
1677/// the input schema, so an input that passed validation carries them all; this
1678/// is the check that the two rules stay in step rather than deriving a key over
1679/// a missing value and collapsing two calls onto one identity.
1680fn client_tool_idempotency_key(
1681    run_id: RunId,
1682    seq: u64,
1683    tool: &str,
1684    key_fields: &[String],
1685    input: &Value,
1686) -> Result<String, ApiError> {
1687    if key_fields.is_empty() {
1688        return Ok(hash_value(&json!({
1689            "run": run_id.as_uuid().to_string(),
1690            "seq": seq,
1691            "tool": tool,
1692        })));
1693    }
1694    let mut identity = serde_json::Map::new();
1695    identity.insert("run".to_owned(), json!(run_id.as_uuid().to_string()));
1696    identity.insert("tool".to_owned(), json!(tool));
1697    for field in key_fields {
1698        let value = input.get(field).ok_or_else(|| {
1699            ApiError::BadRequest(format!(
1700                "tool `{tool}` derives its idempotency key from `{field}`, but the input carries \
1701                 no `{field}`; the key names what the call is, so it cannot be derived without it"
1702            ))
1703        })?;
1704        identity.insert(field.clone(), value.clone());
1705    }
1706    Ok(hash_value(&Value::Object(identity)))
1707}
1708
1709/// `POST /v1/client-runs/{id}/client-tool-intent`: open a client-performed tool
1710/// call.
1711///
1712/// The counterpart of [`tool_step`] for a tool salvor holds no code for. The
1713/// client is about to run the call in its OWN process, with its own secrets;
1714/// this endpoint records that it is about to, so the intent is in the log before
1715/// the effect happens, exactly as the write-ahead rule demands of a call salvor
1716/// performs itself. Requires the `X-Drive-Token` header, like every other
1717/// driving endpoint.
1718///
1719/// What the server takes from the operator's declaration rather than the
1720/// request: the [`Effect`] (so a caller cannot up- or down-grade its own write
1721/// into a freely retried read), the input schema the input is checked against
1722/// before anything is written, and the idempotency key, which is DERIVED here
1723/// (see [`client_tool_idempotency_key`]). The client supplies only the position,
1724/// the name, and the input.
1725///
1726/// The intent goes through the same [`LogValidator`] guard every other append on
1727/// this surface uses, so ordering and correlation stay enforced: an intent at a
1728/// position the log is not ready for is a `409 divergence` and nothing is
1729/// written. A byte-identical re-post at an already-recorded position is a `200`
1730/// that re-derives the same key and writes nothing, the safe retry a dropped
1731/// response leaves behind.
1732///
1733/// The response carries the derived key and a `settled` flag: `true` when the
1734/// intent at this position already has its completion recorded, so a caller
1735/// re-posting an intent it believes it already opened can tell "safe to
1736/// perform" from "already done" without reading the log. The client performs
1737/// the work under the key and then posts [`client_tool_completion`].
1738pub async fn client_tool_intent(
1739    State(state): State<AppState>,
1740    Path(run_id_text): Path<String>,
1741    headers: HeaderMap,
1742    body: Bytes,
1743) -> Result<Json<Value>, ApiError> {
1744    let run_id = parse_run_id(&run_id_text)?;
1745    authorize_drive(&state, run_id, &headers)?;
1746
1747    if body.len() > MAX_EVENTS_BODY {
1748        return Err(ApiError::PayloadTooLarge(format!(
1749            "client-tool-intent body is {} bytes, over the {MAX_EVENTS_BODY}-byte cap",
1750            body.len()
1751        )));
1752    }
1753    let request: ClientToolIntentRequest = parse_body(&body)?;
1754
1755    // The declaration is looked up before anything is written. Declarations are
1756    // loaded by the operator and never registered over HTTP (see
1757    // `crate::client_tools`), so an unknown name is a `404` the operator fixes,
1758    // not something a caller can create for itself.
1759    let decls = state.client_tools();
1760    let decl = decls.get(&request.tool).ok_or_else(|| {
1761        ApiError::UnknownTool(format!(
1762            "no client-performed tool named `{}` is declared on this server; declarations are \
1763             loaded by the operator (`salvor serve --client-tool <FILE>`) and are never \
1764             registered over HTTP",
1765            request.tool
1766        ))
1767    })?;
1768
1769    // The input is checked against the OPERATOR's schema before the intent is
1770    // recorded, so a malformed call never becomes history: on the failure path
1771    // this endpoint writes nothing at all and the run is untouched.
1772    validate_against_schema(&request.input, &decl.input_schema).map_err(|error| {
1773        ApiError::BadRequest(format!(
1774            "the input does not match the declared input_schema for `{}`: {error}",
1775            request.tool
1776        ))
1777    })?;
1778    let effect = decl.effect;
1779    let key_fields = decl.idempotency_key.clone();
1780
1781    let key = client_tool_idempotency_key(
1782        run_id,
1783        request.seq,
1784        &request.tool,
1785        &key_fields,
1786        &request.input,
1787    )?;
1788    let intent = EventEnvelope::new(
1789        run_id,
1790        SequenceNumber::new(request.seq),
1791        state.now(),
1792        Event::ToolCallRequested {
1793            seq: SequenceNumber::new(request.seq),
1794            tool: request.tool.clone(),
1795            input: request.input.clone(),
1796            effect,
1797            idempotency_key: Some(key.clone()),
1798            // The whole point of the stage: the log says who performed this, so
1799            // a later reader can tell a call salvor witnessed from a call it was
1800            // told about.
1801            performed_by: Some(Performer::Client),
1802        },
1803    );
1804
1805    let log = state.store().read_log(run_id).await.map_err(store_error)?;
1806    if (request.seq as usize) < log.len() {
1807        // An already-recorded position. The derivation is a pure function of
1808        // the position or of the input, never of anything this request could
1809        // vary independently, so an identical re-post re-derives the recorded
1810        // key and can simply be handed it back: the client retries its own call
1811        // under the same key and the provider collapses the duplicate. Compare
1812        // the events rather than the envelopes, because `recorded_at` is this
1813        // store's stamp from the first attempt and would never match a fresh one.
1814        let recorded = &log[request.seq as usize];
1815        if recorded.event == intent.event {
1816            let output = recorded_tool_output(&log, request.seq);
1817            return Ok(Json(intent_body(request.seq, &key, effect, output)));
1818        }
1819        return Err(ApiError::Divergence(format!(
1820            "seq {} already holds a different event; it is not this client-tool intent's position",
1821            request.seq
1822        )));
1823    }
1824
1825    // The same append-guard the generic append and both server-performed steps
1826    // push through: it decides whether this is the legal next event. It runs
1827    // before the claim below, and the order matters: a claim is permanent and
1828    // nothing releases it, so claiming an identity for an intent that then
1829    // turns out to be illegal would strand that key forever over a call that
1830    // was never recorded.
1831    let mut validator = LogValidator::new(log);
1832    validator
1833        .push(intent.clone())
1834        .map_err(|error| ApiError::Divergence(error.to_string()))?;
1835
1836    // A declared key is an identity, not an attempt number, so this is the
1837    // moment to find out whether the call it names has already happened. It
1838    // mirrors `RunCtx::tool_call` exactly: the store's claim is the arbiter,
1839    // asked live, before the write-ahead intent is persisted and before the
1840    // client is told it may perform anything.
1841    //
1842    // Only a Write or an Idempotent call asks. A Read has no effect worth an
1843    // identity, and answering a repeated read from an older call would quietly
1844    // freeze a loop that is polling for a change on purpose.
1845    let identity = (!key_fields.is_empty() && deduplicates(effect)).then_some(CallClaimant {
1846        tool: &request.tool,
1847        idempotency_key: &key,
1848        run_id,
1849        intent_seq: SequenceNumber::new(request.seq),
1850    });
1851    let mut copied = None;
1852    if let Some(claimant) = identity {
1853        match state
1854            .store()
1855            .claim_call(claimant)
1856            .await
1857            .map_err(store_error)?
1858        {
1859            // This position is the one execution of the call.
1860            CallClaim::Claimed => {}
1861            CallClaim::Held(commitment) if commitment.completion_seq.is_some() => {
1862                copied = Some(committed_output(&state, commitment).await?);
1863            }
1864            // Held by a call that has not finished. Within one run the
1865            // append-guard's one-pending-call rule refuses the second intent
1866            // before it ever gets here, so this is the guard for the case the
1867            // rule cannot see, and it refuses rather than guessing: nothing is
1868            // recorded, and the run is drivable again the moment the holder is
1869            // settled.
1870            CallClaim::Held(commitment) => {
1871                return Err(ApiError::Divergence(format!(
1872                    "the call `{}` names is already open at seq {} of run {} and has not \
1873                     finished; settle that call before opening the same one again",
1874                    request.tool,
1875                    commitment.intent_seq.get(),
1876                    commitment.run_id.as_uuid()
1877                )));
1878            }
1879        }
1880    }
1881
1882    // Write-ahead on both paths. An intent that resolves as a duplicate is
1883    // still an honest record of what this run asked for, which is the rule
1884    // `RunCtx::tool_call` follows for the same case.
1885    state.store().append(&intent).await.map_err(append_error)?;
1886
1887    if let Some((output, origin)) = copied {
1888        // The call already happened, so nothing performs it a second time: the
1889        // completion is written here, correlated to the intent just recorded
1890        // and naming what it copied.
1891        append_tool_completion(&state, run_id, request.seq, &output, Some(origin)).await?;
1892        return Ok(Json(intent_body(request.seq, &key, effect, Some(output))));
1893    }
1894    // A freshly-recorded intent can never already be settled: the append above
1895    // just placed it at the log's new end, with nothing after it yet.
1896    Ok(Json(intent_body(request.seq, &key, effect, None)))
1897}
1898
1899/// Whether a call with this effect carries an identity worth deduplicating on,
1900/// the same rule `RunCtx::tool_call` applies to a tool's declared key.
1901fn deduplicates(effect: Effect) -> bool {
1902    matches!(effect, Effect::Write | Effect::Idempotent)
1903}
1904
1905/// The recorded output of the completion a settled commitment points at, and
1906/// the origin to name on the copy.
1907///
1908/// The output is read back through `read_log`, so the origin run's hash chain
1909/// is verified before a single byte is copied, exactly as the runtime's own
1910/// deduplication reads it.
1911async fn committed_output(
1912    state: &AppState,
1913    commitment: salvor_store::CallCommitment,
1914) -> Result<(Value, DedupOrigin), ApiError> {
1915    let origin_log = state
1916        .store()
1917        .read_log(commitment.run_id)
1918        .await
1919        .map_err(store_error)?;
1920    let output = recorded_tool_output(&origin_log, commitment.intent_seq.get()).ok_or_else(|| {
1921        ApiError::Internal(format!(
1922            "the store says run {} settled this call at seq {}, but that log holds no completion \
1923             there",
1924            commitment.run_id.as_uuid(),
1925            commitment.intent_seq.get()
1926        ))
1927    })?;
1928    Ok((
1929        output,
1930        DedupOrigin {
1931            run_id: commitment.run_id,
1932            seq: commitment.intent_seq,
1933        },
1934    ))
1935}
1936
1937/// The output recorded for the tool intent at `seq`, when its
1938/// `ToolCallCompleted` is already in `log`; `None` while the call is still
1939/// open.
1940///
1941/// The append-guard only ever admits a completion for the same `seq`
1942/// immediately after its intent (see [`append_tool_completion`]), so it is
1943/// enough to check the very next slot.
1944fn recorded_tool_output(log: &[EventEnvelope], seq: u64) -> Option<Value> {
1945    match &log.get(seq as usize + 1)?.event {
1946        Event::ToolCallCompleted {
1947            seq: completed_seq,
1948            output,
1949            ..
1950        } if completed_seq.get() == seq => Some(output.clone()),
1951        _ => None,
1952    }
1953}
1954
1955/// The `200` client-tool-intent body: the position, the DERIVED idempotency key
1956/// the client must perform under, the operator-declared effect it was
1957/// recorded with, whether this position's completion is ALREADY recorded, and
1958/// that completion's output when it is.
1959///
1960/// `settled` exists for a caller re-posting an intent it already believes it
1961/// opened, most pointedly a payments caller checking a write before it acts on
1962/// the response: without it, a retried intent and a fresh one look identical
1963/// (same `200`, same key), and a caller cannot tell "safe to perform" from
1964/// "already done, do not perform it again" without separately reading the log.
1965///
1966/// The recorded `output` rides along on a settled answer, the same way
1967/// [`client_model_intent_body`] carries a recorded response, and for a sharper
1968/// reason here: a call answered from a DECLARED idempotency key is settled the
1969/// instant its intent is opened, without the client having performed anything,
1970/// so this response is the only place the client learns what the call it just
1971/// asked for returned. The key is omitted entirely while the call is open, so
1972/// an unsettled answer is byte for byte what it was before there was an output
1973/// to carry.
1974fn intent_body(seq: u64, idempotency_key: &str, effect: Effect, output: Option<Value>) -> Value {
1975    let mut body = json!({
1976        "seq": seq,
1977        "idempotency_key": idempotency_key,
1978        "effect": effect,
1979        "settled": output.is_some(),
1980    });
1981    if let Some(output) = output {
1982        body.as_object_mut()
1983            .expect("the intent body is a JSON object")
1984            .insert("output".to_owned(), output);
1985    }
1986    body
1987}
1988
1989/// Which of the two shapes a client-tool completion arrived in, after the
1990/// either-or rule has been applied to the request body.
1991enum Reported {
1992    /// The call returned this.
1993    Output(Value),
1994    /// The call produced nothing and failed like this.
1995    Error(ReportedFailure),
1996}
1997
1998/// Records a client-reported failure as the completion for the intent at `seq`.
1999///
2000/// The recorded output is the `__salvor_error` sentinel, built by
2001/// `salvor_runtime::wire`'s own [`encode_failure`], so the bytes are the ones
2002/// the runtime writes when a native tool exhausts its retries. That parity is
2003/// the whole point: a failure is not a new kind of event and not a new run
2004/// state, it is the outcome a completion is allowed to carry, and a log written
2005/// through this endpoint has to mean to a replay exactly what a natively
2006/// recorded one means.
2007///
2008/// # Errors
2009///
2010/// [`ApiError::BadRequest`] for a `kind` that is not one of the three recorded
2011/// layers, and whatever [`append_tool_completion`] reports.
2012async fn record_reported_failure(
2013    state: &AppState,
2014    run_id: RunId,
2015    seq: u64,
2016    failure: ReportedFailure,
2017) -> Result<Json<Value>, ApiError> {
2018    let kind = match failure.kind.as_deref() {
2019        None => ToolFailureKind::Handler,
2020        Some(named) => ToolFailureKind::from_wire(named).ok_or_else(|| {
2021            ApiError::BadRequest(format!(
2022                "`{named}` is not a failure kind; use `invalid_input`, `handler`, or \
2023                 `output_serialization`, or omit it for `handler`"
2024            ))
2025        })?,
2026    };
2027    let output = encode_failure(&ToolFailure {
2028        kind,
2029        message: failure.message,
2030        // One attempt. `attempts` counts executions inside salvor's own retry
2031        // loop, and salvor ran no loop over a call it did not dispatch, so a
2032        // number taken from the wire would be the client describing machinery
2033        // that never touched its call.
2034        attempts: 1,
2035    });
2036    append_tool_completion(state, run_id, seq, &output, None).await?;
2037    Ok(Json(json!({ "seq": seq, "completed": true })))
2038}
2039
2040/// `POST /v1/client-runs/{id}/client-tool-completion`: record that a
2041/// client-performed tool call finished.
2042///
2043/// The client ran the call in its own process and is now reporting what
2044/// happened. Salvor did not witness it, so everything this endpoint can check,
2045/// it checks before the report becomes history. Requires the `X-Drive-Token`
2046/// header.
2047///
2048/// # Two shapes, and exactly one of them
2049///
2050/// The body carries `output`, what the call returned, or `error`, what went
2051/// wrong instead when it returned nothing at all. Both, or neither, is a `400`:
2052/// they say opposite things about the same call and this server has no way to
2053/// pick between them.
2054///
2055/// The `error` shape records the same `__salvor_error` sentinel completion the
2056/// runtime records when a NATIVE tool exhausts its retries, through
2057/// `salvor_runtime::wire`'s own encoder, so the bytes match (see
2058/// [`record_reported_failure`]). A failure is not a new event and not a new run
2059/// state: it is an outcome a completion is allowed to carry, so a recorded
2060/// failure SETTLES the call exactly as a native one does, and the run carries on
2061/// with the failure replaying from the log rather than the call happening again.
2062///
2063/// It refuses, recording nothing, when:
2064///
2065/// - the body carries both `output` and `error`, or neither (`400`);
2066/// - the log does not end at a tool intent, or ends at one whose `seq` is not
2067///   the one this request names (`409 divergence`);
2068/// - the pending intent was performed by the SERVER (`403`): a client must not
2069///   close a call salvor made, since salvor holds the real result;
2070/// - the declaration says `trust_completion = false` (`403`), for a reported
2071///   failure as much as for a reported result. "It did not land" is a claim
2072///   about money made by the party that benefits from it being believed, so an
2073///   untrusted write is left dangling for a person either way;
2074/// - the declaration carries no `output_schema` AND the body reports an output
2075///   (`403`): with nothing to check the report against, the completion is
2076///   unfalsifiable, which is exactly what the schema exists to prevent. A
2077///   reported failure carries no value to check and is unaffected;
2078/// - the reported output fails the declared `output_schema` (`400`);
2079/// - a `require_equal` field's reported value differs from the value the intent
2080///   recorded (`403`): the output schema is a shape check and cannot know what
2081///   was authorized, so a client report may not alter a pinned field;
2082/// - a reported `kind` names no recorded failure layer (`400`).
2083///
2084/// The checks run in that order: the either-or rule first, then correlation,
2085/// then the trust refusal before any value is compared, then the output shape,
2086/// then the per-field equality. The `output_schema`, `require_equal`, and
2087/// value-shape checks are skipped on the `error` path, which is the absence of a
2088/// value for them to look at rather than a relaxation of the rules.
2089///
2090/// # Where a refused completion leaves the run, and why nothing else changes
2091///
2092/// A refusal is not a dead end and needed no new state to express. The log still
2093/// ends at the recorded `ToolCallRequested`, and for an `Effect::Write` the pure
2094/// fold in `salvor-replay` ALREADY reports that as
2095/// [`RunStatus::NeedsReconciliation`](salvor_replay::RunStatus), because an
2096/// uncompleted write intent as the log's last word is precisely what that status
2097/// means. `POST /v1/client-runs/{id}/resolve` already exists to settle it by
2098/// hand, once a person has verified externally whether the call landed.
2099///
2100/// So `trust_completion = false` is fully implemented here, at the completion
2101/// boundary, and deliberately NOT in `derive_state`. That fold is a pure
2102/// function of the log with no access to declarations, and it must stay that
2103/// way: a log has to mean the same thing to a replay on another machine that
2104/// has never seen this server's `--client-tool` files. A later reader who goes
2105/// looking for the strict mode in the fold will not find it, and that is the
2106/// design, not an omission.
2107pub async fn client_tool_completion(
2108    State(state): State<AppState>,
2109    Path(run_id_text): Path<String>,
2110    headers: HeaderMap,
2111    body: Bytes,
2112) -> Result<Json<Value>, ApiError> {
2113    let run_id = parse_run_id(&run_id_text)?;
2114    authorize_drive(&state, run_id, &headers)?;
2115
2116    if body.len() > MAX_EVENTS_BODY {
2117        return Err(ApiError::PayloadTooLarge(format!(
2118            "client-tool-completion body is {} bytes, over the {MAX_EVENTS_BODY}-byte cap",
2119            body.len()
2120        )));
2121    }
2122    let request: ClientToolCompletionRequest = parse_body(&body)?;
2123    let reported = match (request.output, request.error) {
2124        (Some(output), None) => Reported::Output(output),
2125        (None, Some(error)) => Reported::Error(error),
2126        (Some(_), Some(_)) => {
2127            return Err(ApiError::BadRequest(
2128                "a completion carries `output` or `error`, never both: they say opposite things \
2129                 about the same call"
2130                    .to_owned(),
2131            ));
2132        }
2133        (None, None) => {
2134            return Err(ApiError::BadRequest(
2135                "a completion must carry `output` (what the call returned) or `error` (what went \
2136                 wrong instead)"
2137                    .to_owned(),
2138            ));
2139        }
2140    };
2141
2142    // A completion settles the log's LAST event, which must be the intent this
2143    // request names. Anything else and the client and the log disagree about
2144    // what is outstanding.
2145    let log = state.store().read_log(run_id).await.map_err(store_error)?;
2146    let pending = log.last().ok_or_else(|| {
2147        ApiError::Divergence(format!(
2148            "run {} has recorded nothing, so it has no client-performed tool call to complete",
2149            run_id.as_uuid()
2150        ))
2151    })?;
2152    let Event::ToolCallRequested {
2153        seq: intent_seq,
2154        tool,
2155        input: intent_input,
2156        performed_by,
2157        ..
2158    } = &pending.event
2159    else {
2160        return Err(ApiError::Divergence(format!(
2161            "run {} does not end at a tool intent, so there is no tool call to complete",
2162            run_id.as_uuid()
2163        )));
2164    };
2165    if intent_seq.get() != request.seq {
2166        return Err(ApiError::Divergence(format!(
2167            "the pending tool intent is at seq {}, not the seq {} this completion names",
2168            intent_seq.get(),
2169            request.seq
2170        )));
2171    }
2172    // A client may close only a call a client made. The server-performed
2173    // tool-step records its own completion from the output it saw, so a client
2174    // completion there would be overwriting a witnessed fact with a claim.
2175    if *performed_by != Some(Performer::Client) {
2176        return Err(ApiError::ClientCompletionRefused(format!(
2177            "the pending tool call at seq {} was performed by this server, not by the client, so \
2178             a client may not record its completion",
2179            request.seq
2180        )));
2181    }
2182    let tool = tool.clone();
2183
2184    let decls = state.client_tools();
2185    let decl = decls.get(&tool).ok_or_else(|| {
2186        ApiError::UnknownTool(format!(
2187            "no client-performed tool named `{tool}` is declared on this server, so the completion \
2188             reported for the intent at seq {} cannot be checked",
2189            request.seq
2190        ))
2191    })?;
2192
2193    if !decl.trust_completion {
2194        return Err(ApiError::ClientCompletionRefused(format!(
2195            "tool `{tool}` is declared with trust_completion = false, so a client may not record \
2196             its own completion for it; verify the call externally, then settle it by hand with \
2197             POST /v1/runs/{}/resolve or `salvor resolve`",
2198            run_id.as_uuid()
2199        )));
2200    }
2201    // A reported failure stops here, on the checks that are about trust rather
2202    // than about a value. There is no output to hold against the declared
2203    // shape, and no field to pin to what was authorized, so the two remaining
2204    // guards have nothing to say: skipping them is the absence of a value, not
2205    // a relaxation of the rules. What IS recorded is byte for byte what the
2206    // runtime records when a native tool exhausts its retries, so a log replays
2207    // identically whichever side the call was performed on.
2208    let output = match reported {
2209        Reported::Output(output) => output,
2210        Reported::Error(failure) => {
2211            return record_reported_failure(&state, run_id, request.seq, failure).await;
2212        }
2213    };
2214
2215    let Some(output_schema) = &decl.output_schema else {
2216        return Err(ApiError::ClientCompletionRefused(format!(
2217            "tool `{tool}` declares no output_schema, so a client-reported completion carries \
2218             nothing this server can check; declare an output_schema for it, or settle the call \
2219             by hand with POST /v1/runs/{}/resolve or `salvor resolve`",
2220            run_id.as_uuid()
2221        )));
2222    };
2223    validate_against_schema(&output, output_schema).map_err(|error| {
2224        ApiError::BadRequest(format!(
2225            "the reported output does not match the declared output_schema for `{tool}`: {error}"
2226        ))
2227    })?;
2228
2229    // The output schema is a shape check and cannot know what was authorized, so
2230    // a report claiming a different amount than the intent recorded passes it. A
2231    // require_equal field closes that gap: the reported value must be JSON-equal
2232    // to the value the intent recorded. The load-time rule guarantees each named
2233    // field is required on both sides, so both values are present to compare.
2234    for field in &decl.require_equal {
2235        let authorized = intent_input.get(field).unwrap_or(&Value::Null);
2236        let claimed = output.get(field).unwrap_or(&Value::Null);
2237        if authorized != claimed {
2238            return Err(ApiError::ClientCompletionRefused(format!(
2239                "tool `{tool}` reported `{field}` as {claimed} for the intent at seq {}, but the \
2240                 intent recorded {authorized}; a client report may not alter a require_equal field. \
2241                 If the provider genuinely did something different, settle it by hand with POST \
2242                 /v1/runs/{}/resolve or `salvor resolve`",
2243                request.seq,
2244                run_id.as_uuid()
2245            )));
2246        }
2247    }
2248
2249    // The completion goes through the same guard and the same helper the
2250    // server-performed tool step records its own completion with, so the two
2251    // surfaces write byte-identical `ToolCallCompleted` events.
2252    append_tool_completion(&state, run_id, request.seq, &output, None).await?;
2253    Ok(Json(json!({
2254        "seq": request.seq,
2255        "completed": true,
2256    })))
2257}
2258
2259/// `POST /v1/client-runs/{id}/client-model-intent`: open a model call the
2260/// CLIENT performs.
2261///
2262/// The counterpart of [`model_step`] for a call this server does not make. The
2263/// client is about to call the provider in its OWN process, with its own key
2264/// and its own model configuration; this endpoint records that it is about to,
2265/// so the intent is in the log before the call happens, exactly as the
2266/// write-ahead rule demands of a call salvor performs itself. Requires the
2267/// `X-Drive-Token` header, like every other driving endpoint.
2268///
2269/// # What salvor is trusting, and what it buys
2270///
2271/// [`model_step`] recomputes `request_hash` from the request body it was handed,
2272/// so the client cannot record a hash that does not match what was sent. Here
2273/// it cannot: the request never reaches this server, because this server is not
2274/// the one sending it. The hash is the client's claim over its own request, and
2275/// the recorded response is the client's claim about what came back, in exactly
2276/// the sense a client-performed tool result is (see [`Performer`]). Salvor did
2277/// not witness the call; it is trusting the report.
2278///
2279/// What the trust buys is the whole point of the feature: a resume replays the
2280/// recorded answer instead of paying the provider for it a second time. The
2281/// claim is also self-punishing rather than dangerous to anyone else, which is
2282/// why it is safe to take: the hash is a key into this run's own log, so a
2283/// client that hashes inconsistently diverges against its own history and
2284/// nobody else's.
2285///
2286/// # Replay, mirroring [`client_tool_intent`] exactly
2287///
2288/// A recorded intent at this position whose `request_hash` matches is a replay:
2289/// nothing is written, and the answer carries the recorded completion when one
2290/// exists, so a middleware can short-circuit without a separate log read. A
2291/// different hash there, a non-model event, or an intent the SERVER performed is
2292/// `409 divergence` and nothing is written. A fresh position goes through the
2293/// same [`LogValidator`] guard every other append on this surface uses.
2294pub async fn client_model_intent(
2295    State(state): State<AppState>,
2296    Path(run_id_text): Path<String>,
2297    headers: HeaderMap,
2298    body: Bytes,
2299) -> Result<Json<Value>, ApiError> {
2300    let run_id = parse_run_id(&run_id_text)?;
2301    let lease = authorize_drive(&state, run_id, &headers)?;
2302
2303    if body.len() > MAX_EVENTS_BODY {
2304        return Err(ApiError::PayloadTooLarge(format!(
2305            "client-model-intent body is {} bytes, over the {MAX_EVENTS_BODY}-byte cap",
2306            body.len()
2307        )));
2308    }
2309    let request: ClientModelIntentRequest = parse_body(&body)?;
2310
2311    let log = state.store().read_log(run_id).await.map_err(store_error)?;
2312    if (request.seq as usize) < log.len() {
2313        // An already-recorded position. Correlation is on the hash alone, the
2314        // identical rule [`plan_model_step`] and `ReplayCursor::model_call`
2315        // use: the body is informational and a log captured with bodies must
2316        // replay the same as one captured without, so a re-post that omits the
2317        // body it once sent is still the same call.
2318        let recorded = &log[request.seq as usize];
2319        let Event::ModelCallRequested {
2320            request_hash: recorded_hash,
2321            performed_by,
2322            ..
2323        } = &recorded.event
2324        else {
2325            return Err(ApiError::Divergence(format!(
2326                "seq {} already holds a non-model event; it is not this client-model intent's \
2327                 position",
2328                request.seq
2329            )));
2330        };
2331        if *performed_by != Some(Performer::Client) {
2332            return Err(ApiError::Divergence(format!(
2333                "the model intent at seq {} was performed by this server, not by the client",
2334                request.seq
2335            )));
2336        }
2337        if recorded_hash != &request.request_hash {
2338            return Err(ApiError::Divergence(format!(
2339                "the model intent at seq {} carries a request hash that differs from the recorded \
2340                 one",
2341                request.seq
2342            )));
2343        }
2344        return Ok(Json(client_model_intent_body(
2345            request.seq,
2346            recorded_completion(&log, request.seq),
2347        )));
2348    }
2349
2350    // Recorded only when the run was opened with `record_prompts: true`, the
2351    // same rule and the same `Option::then` shape the server-performed step
2352    // reads off the lease. A body sent with recording off is dropped here and
2353    // never written.
2354    let request_body = lease
2355        .record_prompts
2356        .then_some(request.request_body)
2357        .flatten();
2358    let intent = EventEnvelope::new(
2359        run_id,
2360        SequenceNumber::new(request.seq),
2361        state.now(),
2362        Event::ModelCallRequested {
2363            seq: SequenceNumber::new(request.seq),
2364            request_hash: request.request_hash.clone(),
2365            request_body,
2366            // The whole point of the endpoint: the log says who performed this,
2367            // so a later reader can tell a call salvor witnessed from a call it
2368            // was told about.
2369            performed_by: Some(Performer::Client),
2370        },
2371    );
2372
2373    let mut validator = LogValidator::new(log);
2374    validator
2375        .push(intent.clone())
2376        .map_err(|error| ApiError::Divergence(error.to_string()))?;
2377    state.store().append(&intent).await.map_err(append_error)?;
2378    // A freshly-recorded intent can never already be settled: the append above
2379    // just placed it at the log's new end, with nothing after it yet.
2380    Ok(Json(client_model_intent_body(request.seq, None)))
2381}
2382
2383/// The recorded `(response, usage)` for the model intent at `seq`, when its
2384/// completion is already in `log`.
2385///
2386/// The append-guard only ever admits a completion for the same `seq`
2387/// immediately after its intent, so it is enough to check the very next slot,
2388/// the same way [`intent_is_settled`] checks a tool intent's.
2389fn recorded_completion(log: &[EventEnvelope], seq: u64) -> Option<(&Value, TokenUsage)> {
2390    match &log.get(seq as usize + 1)?.event {
2391        Event::ModelCallCompleted {
2392            seq: completed_seq,
2393            response,
2394            usage,
2395        } if completed_seq.get() == seq => Some((response, *usage)),
2396        _ => None,
2397    }
2398}
2399
2400/// The `200` client-model-intent body: the position, whether this position's
2401/// completion is ALREADY recorded, and, when it is, that completion.
2402///
2403/// `settled` is the same flag [`intent_body`] carries for a tool intent, and it
2404/// is here for a sharper version of the same reason. A middleware re-posting an
2405/// intent it believes it already opened cannot otherwise tell "safe to call the
2406/// provider" from "already called, do not pay for it again", and paying twice
2407/// is precisely what recording the call was for. So the recorded completion
2408/// rides along on a settled answer: the middleware short-circuits on the
2409/// response it already has, with no second request.
2410fn client_model_intent_body(seq: u64, completion: Option<(&Value, TokenUsage)>) -> Value {
2411    match completion {
2412        Some((response, usage)) => json!({
2413            "seq": seq,
2414            "settled": true,
2415            "response": response,
2416            "usage": usage,
2417        }),
2418        None => json!({ "seq": seq, "settled": false }),
2419    }
2420}
2421
2422/// `POST /v1/client-runs/{id}/client-model-completion`: record that a
2423/// client-performed model call finished.
2424///
2425/// The client called the provider in its own process and is now reporting the
2426/// response and what it cost. Requires the `X-Drive-Token` header.
2427///
2428/// It refuses, recording nothing, when:
2429///
2430/// - the log does not end at a model intent, or ends at one whose `seq` is not
2431///   the one this request names (`409 divergence`);
2432/// - the pending intent was performed by the SERVER (`403`): a client must not
2433///   close a call salvor made, since salvor holds the real response.
2434///
2435/// That is the whole list, and it is shorter than [`client_tool_completion`]'s
2436/// on purpose. The tool completion's remaining refusals all come from the
2437/// operator's declaration (`trust_completion`, `output_schema`,
2438/// `require_equal`), and a model call has no such declaration to check against:
2439/// its response shape is the provider's, not an operator's. The response is
2440/// recorded verbatim, as the server-performed step records its own.
2441///
2442/// Once recorded, the completion is byte-identical to a server-performed one
2443/// (it goes through the same [`append_completion`] helper), so the fold treats
2444/// the call exactly the same: pending while open, closed by this event, and its
2445/// tokens counted toward every budget the run is held to.
2446pub async fn client_model_completion(
2447    State(state): State<AppState>,
2448    Path(run_id_text): Path<String>,
2449    headers: HeaderMap,
2450    body: Bytes,
2451) -> Result<Json<Value>, ApiError> {
2452    let run_id = parse_run_id(&run_id_text)?;
2453    authorize_drive(&state, run_id, &headers)?;
2454
2455    if body.len() > MAX_EVENTS_BODY {
2456        return Err(ApiError::PayloadTooLarge(format!(
2457            "client-model-completion body is {} bytes, over the {MAX_EVENTS_BODY}-byte cap",
2458            body.len()
2459        )));
2460    }
2461    let request: ClientModelCompletionRequest = parse_body(&body)?;
2462
2463    // A completion settles the log's LAST event, which must be the intent this
2464    // request names. Anything else and the client and the log disagree about
2465    // what is outstanding.
2466    let log = state.store().read_log(run_id).await.map_err(store_error)?;
2467    let pending = log.last().ok_or_else(|| {
2468        ApiError::Divergence(format!(
2469            "run {} has recorded nothing, so it has no client-performed model call to complete",
2470            run_id.as_uuid()
2471        ))
2472    })?;
2473    let Event::ModelCallRequested {
2474        seq: intent_seq,
2475        performed_by,
2476        ..
2477    } = &pending.event
2478    else {
2479        return Err(ApiError::Divergence(format!(
2480            "run {} does not end at a model intent, so there is no model call to complete",
2481            run_id.as_uuid()
2482        )));
2483    };
2484    if intent_seq.get() != request.seq {
2485        return Err(ApiError::Divergence(format!(
2486            "the pending model intent is at seq {}, not the seq {} this completion names",
2487            intent_seq.get(),
2488            request.seq
2489        )));
2490    }
2491    // A client may close only a call a client made. The server-performed
2492    // model-step records its own completion from the response it saw, so a
2493    // client completion there would be overwriting a witnessed fact with a
2494    // claim.
2495    if *performed_by != Some(Performer::Client) {
2496        return Err(ApiError::ClientCompletionRefused(format!(
2497            "the pending model call at seq {} was performed by this server, not by the client, so \
2498             a client may not record its completion",
2499            request.seq
2500        )));
2501    }
2502
2503    // The same guard and the same helper the server-performed model step
2504    // records its own completion with, so the two surfaces write byte-identical
2505    // `ModelCallCompleted` events.
2506    append_completion(
2507        &state,
2508        run_id,
2509        request.seq,
2510        &request.response,
2511        request.usage,
2512    )
2513    .await?;
2514    Ok(Json(json!({
2515        "seq": request.seq,
2516        "completed": true,
2517    })))
2518}
2519
2520/// Refuses a model or tool event on the generic append: those are recorded
2521/// through the server-performed model-step and tool-step endpoints, or, for a
2522/// call the CLIENT performs in its own process, through the client-tool-intent
2523/// and client-tool-completion endpoints, or the client-model-intent and
2524/// client-model-completion pair. All four kinds stay refused here.
2525///
2526/// A client-performed tool call is possible, in other words; it is just not
2527/// possible by hand-appending an event. That is the same rule the server-
2528/// performed steps live under, and for the same reason: the effect class, the
2529/// input check, and the idempotency key are the server's to decide from an
2530/// operator's declaration, and an event submitted whole would carry the caller's
2531/// answers to all three.
2532///
2533/// A client-performed MODEL call is possible on the same terms, and stays
2534/// refused here for a narrower reason: the endpoints are where `performed_by`
2535/// is stamped, where prompt recording is read off the run's lease rather than
2536/// taken from the request, and where a completion is checked against the intent
2537/// it claims to close. An event submitted whole would carry the caller's
2538/// answers to all three, including the ability to write `performed_by: null` on
2539/// a call salvor never made and pass a claim off as a witnessed fact.
2540fn reject_side_effecting_kind(candidate: &EventEnvelope) -> Result<(), ApiError> {
2541    use salvor_core::Event;
2542    let kind = match &candidate.event {
2543        Event::ModelCallRequested { .. } => "ModelCallRequested",
2544        Event::ModelCallCompleted { .. } => "ModelCallCompleted",
2545        Event::ToolCallRequested { .. } => "ToolCallRequested",
2546        Event::ToolCallCompleted { .. } => "ToolCallCompleted",
2547        _ => return Ok(()),
2548    };
2549    Err(ApiError::UnsupportedEventKind(format!(
2550        "the generic append accepts control and context events only; `{kind}` is recorded through \
2551         the model-step or tool-step endpoint, or, for a call the client performs itself, through \
2552         the client-model or client-tool endpoint pair"
2553    )))
2554}
2555
2556/// Whether `log` leaves the run asleep: the last durable-timer event it holds
2557/// is a `SleepStarted` that no `SleepCompleted` has closed.
2558///
2559/// The pure append-guard is lenient about the pair on purpose, mirroring the
2560/// cursor: a run that is still asleep has recorded only the start, so nothing
2561/// may demand the completion. That leniency leaves one shape it cannot refuse,
2562/// a `SleepCompleted` for a run that was never asleep, and on this surface that
2563/// is a real mistake a driver can make, since here the client hand-appends both
2564/// halves itself. Checking it at the endpoint keeps the pair ordered without
2565/// teaching the shared guard a rule the runtime's own cursor does not enforce.
2566fn is_sleeping(log: &[EventEnvelope]) -> bool {
2567    log.iter()
2568        .rev()
2569        .find_map(|envelope| match &envelope.event {
2570            Event::SleepStarted { .. } => Some(true),
2571            Event::SleepCompleted {} => Some(false),
2572            _ => None,
2573        })
2574        .unwrap_or(false)
2575}
2576
2577/// Whether `log` is a client-driven run's own log, on the log's own evidence:
2578/// the `RunStarted` at its head carries `driven_by: client`.
2579///
2580/// This is the durable half of the answer to "who drives this run". The other
2581/// half is [`AppState::is_client_run`], the in-memory lease registry, which is
2582/// authoritative only for runs this process opened and knows nothing after a
2583/// restart. Every surface that must not become a second writer against a
2584/// client's drive token asks both: [`open`] (to adopt rather than refuse a run
2585/// from an earlier process), [`crate::runs::resume`] (to keep refusing it), and
2586/// the wake sweeper (to keep leaving its timer to its client). Asking only the
2587/// registry would make a restart quietly re-arm this server as a driver of runs
2588/// it does not own.
2589///
2590/// The check itself lives in [`salvor_replay::log_is_client_driven`], the
2591/// pure crate both this server and `salvor-cli`'s `wake` sweep depend on, so
2592/// the two processes that must each leave a client-driven run alone read the
2593/// same marker the same way. This wrapper only narrows visibility to the
2594/// crate, matching the narrower one this module used before the check moved.
2595pub(crate) fn log_is_client_driven(log: &[EventEnvelope]) -> bool {
2596    salvor_replay::log_is_client_driven(log)
2597}
2598
2599/// The per-run lease gate shared by every driving endpoint: the run must be a
2600/// client-driven run this server opened, and the request must carry its current
2601/// drive token in the `X-Drive-Token` header. Returns the lease so the caller
2602/// can read `record_prompts`.
2603fn authorize_drive(
2604    state: &AppState,
2605    run_id: RunId,
2606    headers: &HeaderMap,
2607) -> Result<ClientRunLease, ApiError> {
2608    let lease = state
2609        .client_run(run_id)
2610        .ok_or_else(|| unknown_client_run(run_id))?;
2611    let presented = headers
2612        .get(DRIVE_TOKEN_HEADER)
2613        .and_then(|value| value.to_str().ok());
2614    match presented {
2615        None => Err(ApiError::MissingDriveToken(format!(
2616            "run {} requires a drive token in the `{DRIVE_TOKEN_HEADER}` header",
2617            run_id.as_uuid()
2618        ))),
2619        Some(token) if token != lease.drive_token => Err(ApiError::InvalidDriveToken(format!(
2620            "the presented drive token is not the current lease for run {}",
2621            run_id.as_uuid()
2622        ))),
2623        Some(_) => {
2624            // The driver presented its current token: it is alive. Refresh the
2625            // lease's `last_seen` so the liveness evidence on GET /v1/runs reads
2626            // "attached". This is the whole heartbeat: it rides on the real
2627            // guarded operation, never a separate ping.
2628            state.touch_client_run(run_id);
2629            Ok(lease)
2630        }
2631    }
2632}
2633
2634/// Parses a JSON body into `T`, mapping a decode failure to a `400`.
2635fn parse_body<T: for<'de> Deserialize<'de>>(body: &Bytes) -> Result<T, ApiError> {
2636    serde_json::from_slice(body)
2637        .map_err(|error| ApiError::BadRequest(format!("request body is not valid JSON: {error}")))
2638}
2639
2640/// Parses a run id from its UUID string, mapping a bad id to a `400`.
2641fn parse_run_id(text: &str) -> Result<RunId, ApiError> {
2642    Uuid::parse_str(text).map(RunId::from_uuid).map_err(|_| {
2643        ApiError::BadRequest(format!("`{text}` is not a valid run id (expected a UUID)"))
2644    })
2645}
2646
2647/// Whether a run's log says it is over, so no driver could still be working on
2648/// it and a re-open may take it regardless of any lease left behind.
2649///
2650/// This asks the recorded log, not the lease, because a driver that completed a
2651/// run and then vanished leaves a lease that is still current for the rest of
2652/// the TTL. Refusing a re-open on that would make the last minute of every
2653/// finished run needlessly unopenable, and there is nothing to protect: a
2654/// finished run takes no more appends from anyone.
2655fn run_is_finished(log: &[EventEnvelope]) -> bool {
2656    matches!(
2657        derive_state(log).status,
2658        RunStatus::Completed { .. } | RunStatus::Failed { .. } | RunStatus::Abandoned { .. }
2659    )
2660}
2661
2662/// The refusal for a re-open of a run whose driver still holds a current lease,
2663/// naming how long the hold has left, rounded up to whole seconds by
2664/// [`whole_seconds`] so the number is always a time at which retrying works.
2665fn lease_held(run_id: RunId, remaining: Duration) -> ApiError {
2666    let lapses_in_seconds = whole_seconds(remaining);
2667    ApiError::LeaseHeld {
2668        message: format!(
2669            "another driver holds run {}; its lease lapses in {lapses_in_seconds}s if that \
2670             driver goes quiet, and re-opening works then (or as soon as the run finishes)",
2671            run_id.as_uuid()
2672        ),
2673        lapses_in_seconds,
2674    }
2675}
2676
2677/// A lease duration as the whole seconds the wire carries, for the `lease_held`
2678/// refusal and the heartbeat answer alike.
2679///
2680/// Rounded UP, and never below 1. Rounding down would let a hold with a
2681/// fraction of a second left report `0`, and a caller reading that as "try
2682/// again now" would come straight back into the same refusal; rounding up means
2683/// the number is always a time at which retrying can actually work. The same
2684/// reasoning covers a heartbeat interval: a driver told `0` would beat in a
2685/// tight loop.
2686fn whole_seconds(duration: Duration) -> i64 {
2687    i64::try_from(duration.as_nanos().div_ceil(1_000_000_000))
2688        .unwrap_or(i64::MAX)
2689        .max(1)
2690}
2691
2692/// The not-found error for a run that is not a client-driven run here.
2693fn unknown_client_run(run_id: RunId) -> ApiError {
2694    ApiError::UnknownRun(format!(
2695        "no client-driven run {} on this server; open it first",
2696        run_id.as_uuid()
2697    ))
2698}
2699
2700/// Maps a store read error to a `500`.
2701fn store_error(error: salvor_store::StoreError) -> ApiError {
2702    ApiError::Internal(format!("store: {error}"))
2703}
2704
2705/// Maps a store append error: a position taken out from under a validated batch
2706/// (a lost lease race) is a `409` divergence, anything else a `500`.
2707fn append_error(error: salvor_store::StoreError) -> ApiError {
2708    match error {
2709        salvor_store::StoreError::Conflict { seq, .. } => ApiError::Divergence(format!(
2710            "seq {} was taken by another writer before the append landed",
2711            SequenceNumber::get(seq)
2712        )),
2713        other => ApiError::Internal(format!("store: {other}")),
2714    }
2715}