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