Skip to main content

a3s_code_core/evaluation/
identity.rs

1//! Stable identities and digest helpers used by the evaluation substrate.
2
3use serde::{Deserialize, Serialize};
4use sha2::{Digest, Sha256};
5use thiserror::Error;
6
7pub const EXECUTION_TARGET_SCHEMA_V1: &str = "a3s.code.execution-target.v1";
8pub const EXECUTION_FRAME_SCHEMA_V1: &str = "a3s.code.execution-frame.v1";
9pub const EVALUATION_MAX_ID_BYTES: usize = 256;
10const MAX_BRANCH_BYTES: usize = 256;
11
12#[derive(Debug, Clone, PartialEq, Eq, Error)]
13pub enum IdentityError {
14    #[error("unsupported identity schema")]
15    UnsupportedSchema,
16    #[error("identity field `{0}` is invalid")]
17    InvalidField(&'static str),
18    #[error("identity digest is invalid")]
19    InvalidDigest,
20    #[error("identity serialization failed: {0}")]
21    Serialization(String),
22}
23
24/// Session/run identity used by all evaluation records.
25#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
26#[serde(deny_unknown_fields)]
27pub struct ExecutionTargetV1 {
28    pub schema: String,
29    pub session_id: String,
30    pub run_id: String,
31}
32
33impl ExecutionTargetV1 {
34    pub const fn schema() -> &'static str {
35        EXECUTION_TARGET_SCHEMA_V1
36    }
37
38    pub fn new(session_id: impl Into<String>, run_id: impl Into<String>) -> Self {
39        Self {
40            schema: EXECUTION_TARGET_SCHEMA_V1.to_string(),
41            session_id: session_id.into(),
42            run_id: run_id.into(),
43        }
44    }
45
46    pub fn validate(&self) -> Result<(), IdentityError> {
47        if self.schema != EXECUTION_TARGET_SCHEMA_V1 {
48            return Err(IdentityError::UnsupportedSchema);
49        }
50        validate_id("session_id", &self.session_id)?;
51        validate_id("run_id", &self.run_id)
52    }
53
54    pub fn digest(&self) -> Result<String, IdentityError> {
55        self.validate()?;
56        digest_json("a3s.code.execution-target.identity.v1", self)
57            .map_err(|error| IdentityError::Serialization(error.to_string()))
58    }
59}
60
61/// Runtime frame that records parentage without taking ownership of Cloud
62/// checkpoint/fork lineage.  A child evaluation may point at its parent run;
63/// hosts remain responsible for business lineage and authorization.
64#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
65#[serde(deny_unknown_fields)]
66pub struct ExecutionFrameV1 {
67    pub schema: String,
68    pub target: ExecutionTargetV1,
69    #[serde(default, skip_serializing_if = "Option::is_none")]
70    pub parent: Option<ExecutionTargetV1>,
71    #[serde(default, skip_serializing_if = "Option::is_none")]
72    pub branch: Option<String>,
73    pub generation: u64,
74}
75
76impl ExecutionFrameV1 {
77    pub fn root(target: ExecutionTargetV1) -> Self {
78        Self {
79            schema: EXECUTION_FRAME_SCHEMA_V1.to_string(),
80            target,
81            parent: None,
82            branch: None,
83            generation: 0,
84        }
85    }
86
87    pub fn child(target: ExecutionTargetV1, parent: ExecutionTargetV1) -> Self {
88        Self {
89            schema: EXECUTION_FRAME_SCHEMA_V1.to_string(),
90            target,
91            parent: Some(parent),
92            branch: None,
93            generation: 0,
94        }
95    }
96
97    pub fn validate(&self) -> Result<(), IdentityError> {
98        if self.schema != EXECUTION_FRAME_SCHEMA_V1 {
99            return Err(IdentityError::UnsupportedSchema);
100        }
101        self.target.validate()?;
102        if let Some(parent) = &self.parent {
103            parent.validate()?;
104            if parent == &self.target {
105                return Err(IdentityError::InvalidField("parent"));
106            }
107        }
108        if let Some(branch) = &self.branch {
109            if branch.is_empty()
110                || branch.len() > MAX_BRANCH_BYTES
111                || branch.contains('\0')
112                || branch.contains(['\r', '\n'])
113            {
114                return Err(IdentityError::InvalidField("branch"));
115            }
116        }
117        Ok(())
118    }
119
120    pub fn digest(&self) -> Result<String, IdentityError> {
121        self.validate()?;
122        digest_json("a3s.code.execution-frame.identity.v1", self)
123            .map_err(|error| IdentityError::Serialization(error.to_string()))
124    }
125}
126
127/// Cursor for a run-local append-only event/fact stream.
128#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
129#[serde(deny_unknown_fields)]
130pub struct EventCursorV1 {
131    pub sequence: u64,
132}
133
134impl EventCursorV1 {
135    pub const fn new(sequence: u64) -> Self {
136        Self { sequence }
137    }
138
139    pub fn next(self) -> Option<Self> {
140        self.sequence.checked_add(1).map(Self::new)
141    }
142}
143
144pub fn digest_bytes(domain: &str, bytes: &[u8]) -> String {
145    let mut hasher = Sha256::new();
146    hasher.update(domain.as_bytes());
147    hasher.update([0]);
148    hasher.update(bytes);
149    let digest = hasher.finalize();
150    format!("sha256:{digest:x}")
151}
152
153pub fn digest_json<T: Serialize>(domain: &str, value: &T) -> Result<String, serde_json::Error> {
154    let bytes = serde_json::to_vec(value)?;
155    Ok(digest_bytes(domain, &bytes))
156}
157
158pub fn validate_digest(value: &str) -> Result<(), IdentityError> {
159    let Some(hex) = value.strip_prefix("sha256:") else {
160        return Err(IdentityError::InvalidDigest);
161    };
162    if hex.len() != 64
163        || !hex
164            .bytes()
165            .all(|byte| byte.is_ascii_digit() || matches!(byte, b'a'..=b'f'))
166    {
167        return Err(IdentityError::InvalidDigest);
168    }
169    Ok(())
170}
171
172fn validate_id(field: &'static str, value: &str) -> Result<(), IdentityError> {
173    if value.is_empty()
174        || value.len() > EVALUATION_MAX_ID_BYTES
175        || value.contains('\0')
176        || value.contains(['\r', '\n'])
177    {
178        return Err(IdentityError::InvalidField(field));
179    }
180    Ok(())
181}
182
183#[cfg(test)]
184mod tests {
185    use super::*;
186
187    #[test]
188    fn identity_digest_is_domain_separated_and_canonical() {
189        let target = ExecutionTargetV1::new("session-1", "run-1");
190        let first = target.digest().unwrap();
191        let second = digest_json("a3s.code.execution-target.identity.v1", &target).unwrap();
192        assert_eq!(first, second);
193        assert_ne!(
194            digest_bytes("domain-a", b"same"),
195            digest_bytes("domain-b", b"same")
196        );
197        assert!(validate_digest(&first).is_ok());
198        assert!(validate_digest(&first.to_ascii_uppercase()).is_err());
199    }
200
201    #[test]
202    fn frame_rejects_self_parent_and_cursor_overflow() {
203        let target = ExecutionTargetV1::new("session-1", "run-1");
204        let frame = ExecutionFrameV1 {
205            schema: EXECUTION_FRAME_SCHEMA_V1.to_string(),
206            target: target.clone(),
207            parent: Some(target),
208            branch: None,
209            generation: 0,
210        };
211        assert!(matches!(
212            frame.validate(),
213            Err(IdentityError::InvalidField("parent"))
214        ));
215        assert_eq!(EventCursorV1::new(u64::MAX).next(), None);
216    }
217
218    #[test]
219    fn target_rejects_trailing_line_endings_in_identity_fields() {
220        for run_id in ["run-1\n", "run-1\r", "run-1\r\n"] {
221            let target = ExecutionTargetV1::new("session-1", run_id);
222            assert_eq!(
223                target.validate(),
224                Err(IdentityError::InvalidField("run_id"))
225            );
226        }
227    }
228}