Skip to main content

a3s_code_core/
agent_protocol.rs

1//! Versioned headless protocol owned by A3S Code.
2//!
3//! Cloud and other hosts may transport these values, but A3S Code remains the
4//! authority for Agent session/run lifecycle, event names, cancellation, and
5//! checkpoint recovery. The protocol intentionally contains no Cloud tenant,
6//! scheduler, Workload, Runtime, or provider identity.
7
8use crate::event_protocol::{run_event_envelope_v1, EventEnvelopeV1, EVENT_ENVELOPE_V1_VERSION};
9pub use crate::release::AGENT_PROTOCOL_V1;
10use crate::run::{RunEventPage, RunEventRecord, RunStatus};
11use crate::session_checkpoint::{SessionCheckpointDescriptorV1, SessionLogicalResumeEvidenceV1};
12use base64::Engine as _;
13use serde::{Deserialize, Serialize};
14use sha2::{Digest, Sha256};
15use thiserror::Error;
16
17pub const AGENT_PROTOCOL_MAX_ID_BYTES: usize = 256;
18pub const AGENT_PROTOCOL_MAX_REASON_BYTES: usize = 1_024;
19pub const AGENT_PROTOCOL_MAX_PROMPT_BYTES: usize = 64 * 1024;
20pub const AGENT_PROTOCOL_MAX_EVENT_TYPE_BYTES: usize = 128;
21pub const AGENT_PROTOCOL_MAX_EVENT_PAYLOAD_BYTES: usize = 64 * 1024;
22pub const AGENT_PROTOCOL_MAX_EVENT_METADATA_BYTES: usize = 16 * 1024;
23pub const AGENT_PROTOCOL_MAX_EVENT_RECORD_BYTES: usize = 64 * 1024;
24pub const AGENT_PROTOCOL_MAX_EVENTS_PER_PAGE: usize = 64;
25pub const AGENT_PROTOCOL_MAX_EVENT_PAGE_BYTES: usize = 6 * 1024 * 1024;
26pub const AGENT_PROTOCOL_MAX_CHANGE_SET_BYTES: usize = 4 * 1024 * 1024;
27pub const AGENT_PROTOCOL_MAX_CHANGE_SET_RESPONSE_BYTES: usize =
28    (AGENT_PROTOCOL_MAX_CHANGE_SET_BYTES * 4 / 3) + 128 * 1024;
29pub const AGENT_PROTOCOL_CHANGE_SET_FORMAT_V1: &str = "git_unified_diff_v1";
30pub const AGENT_PROTOCOL_CHANGE_SET_ENCODING_V1: &str = "base64";
31
32/// Canonical HTTP endpoint served by `a3s code harness` for v1 commands.
33pub const AGENT_PROTOCOL_COMMAND_HTTP_PATH_V1: &str = "/v1/agent/commands";
34
35/// Canonical HTTP endpoint served by `a3s code harness` for v1 event pages.
36pub const AGENT_PROTOCOL_EVENT_PAGE_HTTP_PATH_V1: &str = "/v1/agent/events:page";
37
38/// Canonical HTTP endpoint served by `a3s code harness` for immutable run changes.
39pub const AGENT_PROTOCOL_CHANGE_SET_HTTP_PATH_V1: &str = "/v1/agent/changes";
40
41/// Stable validation failures for the headless Agent protocol.
42#[derive(Debug, Clone, PartialEq, Eq, Error)]
43pub enum AgentProtocolError {
44    #[error("unsupported A3S Code Agent protocol schema")]
45    UnsupportedSchema,
46    #[error("invalid A3S Code Agent protocol field: {0}")]
47    InvalidField(&'static str),
48    #[error("A3S Code Agent protocol identity or sequence does not match")]
49    IdentityMismatch,
50    #[error("A3S Code Agent protocol value exceeds its bounded encoding")]
51    Encoding,
52}
53
54impl AgentProtocolError {
55    /// Stable machine-readable error code for SDK and service boundaries.
56    pub const fn code(&self) -> &'static str {
57        match self {
58            Self::UnsupportedSchema => "a3s.code.agent_protocol.unsupported_schema",
59            Self::InvalidField(_) => "a3s.code.agent_protocol.invalid_field",
60            Self::IdentityMismatch => "a3s.code.agent_protocol.identity_mismatch",
61            Self::Encoding => "a3s.code.agent_protocol.encoding",
62        }
63    }
64}
65
66/// Exact A3S Code release, session, and run selected by a host.
67#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
68#[serde(deny_unknown_fields)]
69pub struct AgentProtocolRunIdentityV1 {
70    pub schema: String,
71    pub protocol: String,
72    pub agent_release_identity: String,
73    pub session_id: String,
74    pub run_id: String,
75}
76
77impl AgentProtocolRunIdentityV1 {
78    pub const SCHEMA: &'static str = "a3s.code.agent-run-identity.v1";
79
80    pub fn validate(&self) -> Result<(), AgentProtocolError> {
81        validate_schema(&self.schema, Self::SCHEMA)?;
82        if self.protocol != AGENT_PROTOCOL_V1 {
83            return Err(AgentProtocolError::InvalidField("protocol"));
84        }
85        validate_lower_sha256("agent_release_identity", &self.agent_release_identity)?;
86        validate_id("session_id", &self.session_id)?;
87        validate_id("run_id", &self.run_id)
88    }
89
90    pub fn digest(&self) -> Result<String, AgentProtocolError> {
91        digest_validated(self, || self.validate())
92    }
93}
94
95/// Start a fresh A3S Code run with an exact host-selected identity.
96#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
97#[serde(deny_unknown_fields)]
98pub struct AgentProtocolRunStartV1 {
99    pub schema: String,
100    pub request_id: String,
101    pub identity: AgentProtocolRunIdentityV1,
102    pub prompt: String,
103}
104
105impl AgentProtocolRunStartV1 {
106    pub const SCHEMA: &'static str = "a3s.code.agent-run-start.v1";
107
108    pub fn validate(&self) -> Result<(), AgentProtocolError> {
109        validate_schema(&self.schema, Self::SCHEMA)?;
110        validate_id("request_id", &self.request_id)?;
111        self.identity.validate()?;
112        if self.prompt.trim().is_empty()
113            || self.prompt.len() > AGENT_PROTOCOL_MAX_PROMPT_BYTES
114            || self.prompt.contains('\0')
115        {
116            return Err(AgentProtocolError::InvalidField("prompt"));
117        }
118        Ok(())
119    }
120}
121
122/// Cancel the current exact A3S Code run.
123#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
124#[serde(deny_unknown_fields)]
125pub struct AgentProtocolRunCancelV1 {
126    pub schema: String,
127    pub request_id: String,
128    pub identity: AgentProtocolRunIdentityV1,
129    pub reason: String,
130}
131
132impl AgentProtocolRunCancelV1 {
133    pub const SCHEMA: &'static str = "a3s.code.agent-run-cancel.v1";
134
135    pub fn validate(&self) -> Result<(), AgentProtocolError> {
136        validate_schema(&self.schema, Self::SCHEMA)?;
137        validate_id("request_id", &self.request_id)?;
138        self.identity.validate()?;
139        validate_single_line("reason", &self.reason, AGENT_PROTOCOL_MAX_REASON_BYTES)
140    }
141}
142
143/// Resume an A3S Code loop checkpoint into a fresh exact run identity.
144#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
145#[serde(deny_unknown_fields)]
146pub struct AgentProtocolRunRecoverV1 {
147    pub schema: String,
148    pub request_id: String,
149    pub identity: AgentProtocolRunIdentityV1,
150    pub checkpoint_run_id: String,
151}
152
153impl AgentProtocolRunRecoverV1 {
154    pub const SCHEMA: &'static str = "a3s.code.agent-run-recover.v1";
155
156    pub fn validate(&self) -> Result<(), AgentProtocolError> {
157        validate_schema(&self.schema, Self::SCHEMA)?;
158        validate_id("request_id", &self.request_id)?;
159        self.identity.validate()?;
160        validate_id("checkpoint_run_id", &self.checkpoint_run_id)?;
161        if self.checkpoint_run_id == self.identity.run_id {
162            return Err(AgentProtocolError::InvalidField("checkpoint_run_id"));
163        }
164        Ok(())
165    }
166}
167
168/// Resume one exact, content-addressed A3S Code tool-round boundary.
169///
170/// This is an additive recovery request beside [`AgentProtocolRunRecoverV1`].
171/// The original request keeps its "latest checkpoint for this run" semantics
172/// and wire shape, while this request binds admission and its receipt to the
173/// complete secret-free descriptor of one portable Session checkpoint. The
174/// descriptor covers the semantic snapshot, logical-resume boundary, and
175/// aggregate canonical payload identity.
176#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
177#[serde(deny_unknown_fields)]
178pub struct AgentProtocolRunRecoverExactV1 {
179    pub schema: String,
180    pub request_id: String,
181    pub identity: AgentProtocolRunIdentityV1,
182    pub checkpoint: SessionCheckpointDescriptorV1,
183}
184
185impl AgentProtocolRunRecoverExactV1 {
186    pub const SCHEMA: &'static str = "a3s.code.agent-run-recover-exact.v1";
187
188    pub fn validate(&self) -> Result<(), AgentProtocolError> {
189        validate_schema(&self.schema, Self::SCHEMA)?;
190        validate_id("request_id", &self.request_id)?;
191        self.identity.validate()?;
192        self.checkpoint
193            .validate()
194            .map_err(|_| AgentProtocolError::InvalidField("checkpoint"))?;
195        let logical_resume = self.logical_resume()?;
196        if self.checkpoint.snapshot.session_id != self.identity.session_id
197            || logical_resume.session_id != self.identity.session_id
198            || logical_resume.source_run_id == self.identity.run_id
199        {
200            return Err(AgentProtocolError::InvalidField("checkpoint"));
201        }
202        Ok(())
203    }
204
205    pub fn logical_resume(&self) -> Result<&SessionLogicalResumeEvidenceV1, AgentProtocolError> {
206        self.checkpoint
207            .logical_resume
208            .as_ref()
209            .ok_or(AgentProtocolError::InvalidField("checkpoint"))
210    }
211
212    pub fn digest(&self) -> Result<String, AgentProtocolError> {
213        digest_validated(self, || self.validate())
214    }
215}
216
217/// Closed actions accepted by the version-one Code Agent protocol.
218#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
219#[serde(rename_all = "snake_case")]
220pub enum AgentProtocolCommandActionV1 {
221    Start,
222    Cancel,
223    Recover,
224}
225
226/// One typed command for the A3S Code session/run lifecycle.
227#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
228#[serde(tag = "action", rename_all = "snake_case", deny_unknown_fields)]
229pub enum AgentProtocolCommandV1 {
230    Start { request: AgentProtocolRunStartV1 },
231    Cancel { request: AgentProtocolRunCancelV1 },
232    Recover { request: AgentProtocolRunRecoverV1 },
233}
234
235impl AgentProtocolCommandV1 {
236    pub const fn action(&self) -> AgentProtocolCommandActionV1 {
237        match self {
238            Self::Start { .. } => AgentProtocolCommandActionV1::Start,
239            Self::Cancel { .. } => AgentProtocolCommandActionV1::Cancel,
240            Self::Recover { .. } => AgentProtocolCommandActionV1::Recover,
241        }
242    }
243
244    pub fn request_id(&self) -> &str {
245        match self {
246            Self::Start { request } => &request.request_id,
247            Self::Cancel { request } => &request.request_id,
248            Self::Recover { request } => &request.request_id,
249        }
250    }
251
252    pub fn identity(&self) -> &AgentProtocolRunIdentityV1 {
253        match self {
254            Self::Start { request } => &request.identity,
255            Self::Cancel { request } => &request.identity,
256            Self::Recover { request } => &request.identity,
257        }
258    }
259
260    pub fn validate(&self) -> Result<(), AgentProtocolError> {
261        match self {
262            Self::Start { request } => request.validate(),
263            Self::Cancel { request } => request.validate(),
264            Self::Recover { request } => request.validate(),
265        }
266    }
267
268    pub fn digest(&self) -> Result<String, AgentProtocolError> {
269        digest_validated(self, || self.validate())
270    }
271}
272
273/// Stable wire projection of [`RunStatus`].
274#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
275#[serde(rename_all = "snake_case")]
276pub enum AgentProtocolRunStateV1 {
277    Created,
278    Planning,
279    Executing,
280    Verifying,
281    Completed,
282    Failed,
283    Cancelled,
284}
285
286impl AgentProtocolRunStateV1 {
287    pub const fn as_str(self) -> &'static str {
288        match self {
289            Self::Created => "created",
290            Self::Planning => "planning",
291            Self::Executing => "executing",
292            Self::Verifying => "verifying",
293            Self::Completed => "completed",
294            Self::Failed => "failed",
295            Self::Cancelled => "cancelled",
296        }
297    }
298
299    pub const fn is_terminal(self) -> bool {
300        matches!(self, Self::Completed | Self::Failed | Self::Cancelled)
301    }
302}
303
304impl From<RunStatus> for AgentProtocolRunStateV1 {
305    fn from(value: RunStatus) -> Self {
306        match value {
307            RunStatus::Created => Self::Created,
308            RunStatus::Planning => Self::Planning,
309            RunStatus::Executing => Self::Executing,
310            RunStatus::Verifying => Self::Verifying,
311            RunStatus::Completed => Self::Completed,
312            RunStatus::Failed => Self::Failed,
313            RunStatus::Cancelled => Self::Cancelled,
314        }
315    }
316}
317
318/// Exact observation returned after A3S Code accepts a command.
319#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
320#[serde(deny_unknown_fields)]
321pub struct AgentProtocolCommandReceiptV1 {
322    pub schema: String,
323    pub action: AgentProtocolCommandActionV1,
324    pub request_id: String,
325    pub identity: AgentProtocolRunIdentityV1,
326    pub command_digest: String,
327    pub state: AgentProtocolRunStateV1,
328    pub latest_event_sequence_exclusive: u64,
329    pub observed_at_ms: u64,
330    pub replayed: bool,
331}
332
333impl AgentProtocolCommandReceiptV1 {
334    pub const SCHEMA: &'static str = "a3s.code.agent-command-receipt.v1";
335
336    pub fn validate(&self) -> Result<(), AgentProtocolError> {
337        validate_schema(&self.schema, Self::SCHEMA)?;
338        validate_id("request_id", &self.request_id)?;
339        self.identity.validate()?;
340        validate_lower_sha256("command_digest", &self.command_digest)?;
341        if self.observed_at_ms == 0 {
342            return Err(AgentProtocolError::InvalidField("observed_at_ms"));
343        }
344        Ok(())
345    }
346
347    pub fn validate_for(&self, command: &AgentProtocolCommandV1) -> Result<(), AgentProtocolError> {
348        command.validate()?;
349        self.validate()?;
350        if self.action != command.action()
351            || self.request_id != command.request_id()
352            || self.identity != *command.identity()
353            || self.command_digest != command.digest()?
354        {
355            return Err(AgentProtocolError::IdentityMismatch);
356        }
357        if self.action == AgentProtocolCommandActionV1::Cancel && !self.state.is_terminal() {
358            return Err(AgentProtocolError::InvalidField("state"));
359        }
360        Ok(())
361    }
362
363    /// Verify that this receipt settles one exact checkpoint recovery request.
364    pub fn validate_for_exact_recovery(
365        &self,
366        request: &AgentProtocolRunRecoverExactV1,
367    ) -> Result<(), AgentProtocolError> {
368        request.validate()?;
369        self.validate()?;
370        if self.action != AgentProtocolCommandActionV1::Recover
371            || self.request_id != request.request_id
372            || self.identity != request.identity
373            || self.command_digest != request.digest()?
374        {
375            return Err(AgentProtocolError::IdentityMismatch);
376        }
377        Ok(())
378    }
379}
380
381/// One authoritative A3S Code event at its run-local sequence.
382#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
383#[serde(deny_unknown_fields)]
384pub struct AgentProtocolEventRecordV1 {
385    pub sequence: u64,
386    pub occurred_at_ms: u64,
387    pub event: EventEnvelopeV1,
388}
389
390impl AgentProtocolEventRecordV1 {
391    pub fn from_run_event(
392        record: &RunEventRecord,
393        identity: &AgentProtocolRunIdentityV1,
394    ) -> Result<Self, AgentProtocolError> {
395        identity.validate()?;
396        let sequence = u64::try_from(record.sequence)
397            .map_err(|_| AgentProtocolError::InvalidField("sequence"))?;
398        let event = run_event_envelope_v1(record, &identity.run_id, &identity.session_id)
399            .map_err(|_| AgentProtocolError::Encoding)?;
400        let projected = Self {
401            sequence,
402            occurred_at_ms: record.timestamp_ms,
403            event,
404        };
405        projected.validate_for(identity)?;
406        Ok(projected)
407    }
408
409    /// Validate this exact record against its Code-owned run identity.
410    ///
411    /// Hosts use this at durable ingestion boundaries instead of copying the
412    /// event metadata and sequence rules into their own protocol layer.
413    pub fn validate_for(
414        &self,
415        identity: &AgentProtocolRunIdentityV1,
416    ) -> Result<(), AgentProtocolError> {
417        if self.event.version != EVENT_ENVELOPE_V1_VERSION {
418            return Err(AgentProtocolError::InvalidField("event.version"));
419        }
420        validate_single_line(
421            "event.type",
422            &self.event.event_type,
423            AGENT_PROTOCOL_MAX_EVENT_TYPE_BYTES,
424        )?;
425        validate_json_size(
426            "event.payload",
427            &self.event.payload,
428            AGENT_PROTOCOL_MAX_EVENT_PAYLOAD_BYTES,
429        )?;
430        let metadata = self
431            .event
432            .metadata
433            .as_ref()
434            .ok_or(AgentProtocolError::InvalidField("event.metadata"))?;
435        validate_json_size(
436            "event.metadata",
437            metadata,
438            AGENT_PROTOCOL_MAX_EVENT_METADATA_BYTES,
439        )?;
440        let metadata = metadata
441            .as_object()
442            .ok_or(AgentProtocolError::InvalidField("event.metadata"))?;
443        let exact = metadata.get("session_id").and_then(|value| value.as_str())
444            == Some(identity.session_id.as_str())
445            && metadata.get("run_id").and_then(|value| value.as_str())
446                == Some(identity.run_id.as_str())
447            && metadata.get("sequence").and_then(|value| value.as_u64()) == Some(self.sequence)
448            && metadata
449                .get("timestamp_ms")
450                .and_then(|value| value.as_u64())
451                == Some(self.occurred_at_ms);
452        if !exact {
453            return Err(AgentProtocolError::IdentityMismatch);
454        }
455        let encoded = serde_json::to_vec(self).map_err(|_| AgentProtocolError::Encoding)?;
456        if encoded.len() > AGENT_PROTOCOL_MAX_EVENT_RECORD_BYTES {
457            return Err(AgentProtocolError::InvalidField("event"));
458        }
459        Ok(())
460    }
461}
462
463/// Bounded cursor query accepted by the A3S Code Harness event endpoint.
464#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
465#[serde(deny_unknown_fields)]
466pub struct AgentProtocolEventPageRequestV1 {
467    pub schema: String,
468    pub identity: AgentProtocolRunIdentityV1,
469    pub after_event_sequence: Option<u64>,
470    pub limit: u16,
471}
472
473impl AgentProtocolEventPageRequestV1 {
474    pub const SCHEMA: &'static str = "a3s.code.agent-event-page-request.v1";
475
476    pub fn validate(&self) -> Result<(), AgentProtocolError> {
477        validate_schema(&self.schema, Self::SCHEMA)?;
478        self.identity.validate()?;
479        if self.limit == 0 || usize::from(self.limit) > AGENT_PROTOCOL_MAX_EVENTS_PER_PAGE {
480            return Err(AgentProtocolError::InvalidField("limit"));
481        }
482        Ok(())
483    }
484
485    pub fn digest(&self) -> Result<String, AgentProtocolError> {
486        digest_validated(self, || self.validate())
487    }
488}
489
490/// Cursor page projected directly from A3S Code's authoritative run store.
491#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
492#[serde(deny_unknown_fields)]
493pub struct AgentProtocolEventPageV1 {
494    pub schema: String,
495    pub identity: AgentProtocolRunIdentityV1,
496    pub after_event_sequence: Option<u64>,
497    pub first_available_sequence: Option<u64>,
498    pub latest_sequence_exclusive: u64,
499    pub next_after_event_sequence: Option<u64>,
500    pub state: AgentProtocolRunStateV1,
501    pub observed_at_ms: u64,
502    pub retention_gap: bool,
503    pub has_more: bool,
504    pub events: Vec<AgentProtocolEventRecordV1>,
505}
506
507impl AgentProtocolEventPageV1 {
508    pub const SCHEMA: &'static str = "a3s.code.agent-event-page.v1";
509
510    pub fn from_run_page(
511        identity: AgentProtocolRunIdentityV1,
512        state: RunStatus,
513        observed_at_ms: u64,
514        after_event_sequence: Option<usize>,
515        page: &RunEventPage,
516    ) -> Result<Self, AgentProtocolError> {
517        identity.validate()?;
518        let convert = |value: usize| {
519            u64::try_from(value).map_err(|_| AgentProtocolError::InvalidField("sequence"))
520        };
521        let events = page
522            .events
523            .iter()
524            .map(|record| AgentProtocolEventRecordV1::from_run_event(record, &identity))
525            .collect::<Result<Vec<_>, _>>()?;
526        let projected = Self {
527            schema: Self::SCHEMA.into(),
528            identity,
529            after_event_sequence: after_event_sequence.map(convert).transpose()?,
530            first_available_sequence: page.first_available_sequence.map(convert).transpose()?,
531            latest_sequence_exclusive: convert(page.latest_sequence_exclusive)?,
532            next_after_event_sequence: page.next_after_sequence.map(convert).transpose()?,
533            state: state.into(),
534            observed_at_ms,
535            retention_gap: page.retention_gap,
536            has_more: page.has_more,
537            events,
538        };
539        projected.validate()?;
540        Ok(projected)
541    }
542
543    pub fn validate(&self) -> Result<(), AgentProtocolError> {
544        validate_schema(&self.schema, Self::SCHEMA)?;
545        self.identity.validate()?;
546        if self.events.len() > AGENT_PROTOCOL_MAX_EVENTS_PER_PAGE {
547            return Err(AgentProtocolError::InvalidField("events"));
548        }
549        if self
550            .after_event_sequence
551            .is_some_and(|sequence| sequence >= self.latest_sequence_exclusive)
552        {
553            return Err(AgentProtocolError::InvalidField("after_event_sequence"));
554        }
555        if self
556            .first_available_sequence
557            .is_some_and(|sequence| sequence >= self.latest_sequence_exclusive)
558        {
559            return Err(AgentProtocolError::InvalidField("first_available_sequence"));
560        }
561
562        let requested_start = self
563            .after_event_sequence
564            .map(|sequence| sequence.saturating_add(1))
565            .unwrap_or(0);
566        let expected_gap = requested_start < self.latest_sequence_exclusive
567            && self
568                .first_available_sequence
569                .is_none_or(|first| requested_start < first);
570        if self.retention_gap != expected_gap {
571            return Err(AgentProtocolError::InvalidField("retention_gap"));
572        }
573
574        let mut previous: Option<(u64, u64)> = None;
575        for event in &self.events {
576            event.validate_for(&self.identity)?;
577            if event.occurred_at_ms > self.observed_at_ms
578                || previous.is_some_and(|(sequence, timestamp)| {
579                    event.sequence != sequence.saturating_add(1) || event.occurred_at_ms < timestamp
580                })
581                || event.sequence >= self.latest_sequence_exclusive
582            {
583                return Err(AgentProtocolError::InvalidField("events"));
584            }
585            previous = Some((event.sequence, event.occurred_at_ms));
586        }
587
588        if let Some(first) = self.events.first() {
589            if (!self.retention_gap && first.sequence != requested_start)
590                || (self.retention_gap && self.first_available_sequence != Some(first.sequence))
591                || self
592                    .first_available_sequence
593                    .is_some_and(|available| first.sequence < available)
594            {
595                return Err(AgentProtocolError::InvalidField("events"));
596            }
597        } else if self.retention_gap && self.first_available_sequence.is_some() {
598            return Err(AgentProtocolError::InvalidField("events"));
599        }
600        let expected_next = self
601            .events
602            .last()
603            .map(|event| event.sequence)
604            .or(self.after_event_sequence);
605        if self.next_after_event_sequence != expected_next {
606            return Err(AgentProtocolError::InvalidField(
607                "next_after_event_sequence",
608            ));
609        }
610        if self.has_more {
611            if self.events.last().is_none_or(|event| {
612                event.sequence.saturating_add(1) >= self.latest_sequence_exclusive
613            }) {
614                return Err(AgentProtocolError::InvalidField("has_more"));
615            }
616        } else if let Some(last) = self.events.last() {
617            if last.sequence.saturating_add(1) != self.latest_sequence_exclusive {
618                return Err(AgentProtocolError::InvalidField("has_more"));
619            }
620        }
621        let encoded = serde_json::to_vec(self).map_err(|_| AgentProtocolError::Encoding)?;
622        if encoded.len() > AGENT_PROTOCOL_MAX_EVENT_PAGE_BYTES {
623            return Err(AgentProtocolError::InvalidField("events"));
624        }
625        Ok(())
626    }
627
628    pub fn first_sequence(&self) -> Option<u64> {
629        self.events.first().map(|event| event.sequence)
630    }
631
632    pub fn last_sequence(&self) -> Option<u64> {
633        self.events.last().map(|event| event.sequence)
634    }
635
636    pub fn digest(&self) -> Result<String, AgentProtocolError> {
637        digest_validated(self, || self.validate())
638    }
639}
640
641/// Exact run query accepted by the immutable change-set endpoint.
642#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
643#[serde(deny_unknown_fields)]
644pub struct AgentProtocolChangeSetRequestV1 {
645    pub schema: String,
646    pub identity: AgentProtocolRunIdentityV1,
647}
648
649impl AgentProtocolChangeSetRequestV1 {
650    pub const SCHEMA: &'static str = "a3s.code.agent-change-set-request.v1";
651
652    pub fn validate(&self) -> Result<(), AgentProtocolError> {
653        validate_schema(&self.schema, Self::SCHEMA)?;
654        self.identity.validate()
655    }
656
657    pub fn digest(&self) -> Result<String, AgentProtocolError> {
658        digest_validated(self, || self.validate())
659    }
660}
661
662/// Immutable Git-compatible unified diff captured for one terminal Code run.
663///
664/// The base and result tree identities bind the diff to the exact workspace
665/// generations observed immediately before and after the run. The content
666/// digest and byte count let transports and local apply clients fail closed on
667/// truncation or mutation without inventing another run or checkpoint model.
668#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
669#[serde(deny_unknown_fields)]
670pub struct AgentProtocolChangeSetV1 {
671    pub schema: String,
672    pub identity: AgentProtocolRunIdentityV1,
673    pub state: AgentProtocolRunStateV1,
674    pub format: String,
675    pub encoding: String,
676    pub base_tree: String,
677    pub result_tree: String,
678    pub patch_digest: String,
679    pub patch_bytes: u64,
680    pub patch_base64: String,
681    pub observed_at_ms: u64,
682}
683
684impl AgentProtocolChangeSetV1 {
685    pub const SCHEMA: &'static str = "a3s.code.agent-change-set.v1";
686
687    pub fn validate(&self) -> Result<(), AgentProtocolError> {
688        validate_schema(&self.schema, Self::SCHEMA)?;
689        self.identity.validate()?;
690        if !self.state.is_terminal() {
691            return Err(AgentProtocolError::InvalidField("state"));
692        }
693        if self.format != AGENT_PROTOCOL_CHANGE_SET_FORMAT_V1 {
694            return Err(AgentProtocolError::InvalidField("format"));
695        }
696        if self.encoding != AGENT_PROTOCOL_CHANGE_SET_ENCODING_V1 {
697            return Err(AgentProtocolError::InvalidField("encoding"));
698        }
699        validate_git_tree("base_tree", &self.base_tree)?;
700        validate_git_tree("result_tree", &self.result_tree)?;
701        validate_lower_sha256("patch_digest", &self.patch_digest)?;
702        let declared_bytes = usize::try_from(self.patch_bytes)
703            .map_err(|_| AgentProtocolError::InvalidField("patch_bytes"))?;
704        let patch = base64::engine::general_purpose::STANDARD
705            .decode(&self.patch_base64)
706            .map_err(|_| AgentProtocolError::InvalidField("patch_base64"))?;
707        if declared_bytes != patch.len()
708            || declared_bytes > AGENT_PROTOCOL_MAX_CHANGE_SET_BYTES
709            || self.patch_digest != format!("sha256:{:x}", Sha256::digest(&patch))
710            || self.observed_at_ms == 0
711        {
712            return Err(AgentProtocolError::InvalidField("patch_base64"));
713        }
714        let encoded = serde_json::to_vec(self).map_err(|_| AgentProtocolError::Encoding)?;
715        if encoded.len() > AGENT_PROTOCOL_MAX_CHANGE_SET_RESPONSE_BYTES {
716            return Err(AgentProtocolError::InvalidField("patch_base64"));
717        }
718        Ok(())
719    }
720
721    pub fn digest(&self) -> Result<String, AgentProtocolError> {
722        digest_validated(self, || self.validate())
723    }
724}
725
726fn validate_schema(value: &str, expected: &str) -> Result<(), AgentProtocolError> {
727    if value == expected {
728        Ok(())
729    } else {
730        Err(AgentProtocolError::UnsupportedSchema)
731    }
732}
733
734fn validate_id(field: &'static str, value: &str) -> Result<(), AgentProtocolError> {
735    validate_single_line(field, value, AGENT_PROTOCOL_MAX_ID_BYTES)
736}
737
738fn validate_single_line(
739    field: &'static str,
740    value: &str,
741    max: usize,
742) -> Result<(), AgentProtocolError> {
743    if value.trim().is_empty()
744        || value.len() > max
745        || value.contains('\0')
746        || value.contains(['\r', '\n'])
747    {
748        Err(AgentProtocolError::InvalidField(field))
749    } else {
750        Ok(())
751    }
752}
753
754pub(crate) fn validate_lower_sha256(
755    field: &'static str,
756    value: &str,
757) -> Result<(), AgentProtocolError> {
758    let valid = value.strip_prefix("sha256:").is_some_and(|hex| {
759        hex.len() == 64
760            && hex
761                .bytes()
762                .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
763    });
764    if valid {
765        Ok(())
766    } else {
767        Err(AgentProtocolError::InvalidField(field))
768    }
769}
770
771fn validate_git_tree(field: &'static str, value: &str) -> Result<(), AgentProtocolError> {
772    let valid = value.strip_prefix("git-tree:").is_some_and(|hex| {
773        matches!(hex.len(), 40 | 64)
774            && hex
775                .bytes()
776                .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
777    });
778    if valid {
779        Ok(())
780    } else {
781        Err(AgentProtocolError::InvalidField(field))
782    }
783}
784
785fn validate_json_size(
786    field: &'static str,
787    value: &serde_json::Value,
788    max: usize,
789) -> Result<(), AgentProtocolError> {
790    let encoded = serde_json::to_vec(value).map_err(|_| AgentProtocolError::Encoding)?;
791    if encoded.len() > max {
792        Err(AgentProtocolError::InvalidField(field))
793    } else {
794        Ok(())
795    }
796}
797
798fn digest_validated<T: Serialize>(
799    value: &T,
800    validate: impl FnOnce() -> Result<(), AgentProtocolError>,
801) -> Result<String, AgentProtocolError> {
802    validate()?;
803    let encoded = serde_json::to_vec(value).map_err(|_| AgentProtocolError::Encoding)?;
804    Ok(format!("sha256:{:x}", Sha256::digest(encoded)))
805}