Skip to main content

kranz_engine/gate_evaluation/
protocol.rs

1//! Typed wire contract matching the shipped v1 schemas. Structural checks
2//! complement, and never replace, byte binding and filesystem checks.
3use serde::{Deserialize, Serialize};
4use std::collections::BTreeSet;
5
6const MAX_JSON_INTEGER: u64 = 9_007_199_254_740_991;
7
8fn require(condition: bool, message: &str) -> Result<(), String> {
9    condition.then_some(()).ok_or_else(|| message.to_string())
10}
11
12#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
13#[serde(try_from = "String")]
14pub struct Id(String);
15impl TryFrom<String> for Id {
16    type Error = String;
17    fn try_from(value: String) -> Result<Self, String> {
18        require(
19            !value.is_empty()
20                && value.len() <= 128
21                && value.as_bytes()[0].is_ascii_alphanumeric()
22                && value
23                    .bytes()
24                    .all(|c| c.is_ascii_alphanumeric() || b"_.-".contains(&c)),
25            "invalid gate identifier",
26        )?;
27        Ok(Self(value))
28    }
29}
30impl Id {
31    pub fn as_str(&self) -> &str {
32        &self.0
33    }
34}
35
36#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
37#[serde(try_from = "String")]
38pub struct Digest(String);
39impl TryFrom<String> for Digest {
40    type Error = String;
41    fn try_from(value: String) -> Result<Self, String> {
42        require(
43            value.strip_prefix("sha256:").is_some_and(|hex| {
44                hex.len() == 64
45                    && hex
46                        .bytes()
47                        .all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b))
48            }),
49            "invalid SHA-256 digest",
50        )?;
51        Ok(Self(value))
52    }
53}
54impl Digest {
55    pub fn of(bytes: &[u8]) -> Self {
56        use sha2::{Digest as _, Sha256};
57        Self(format!(
58            "sha256:{}",
59            Sha256::digest(bytes)
60                .iter()
61                .map(|b| format!("{b:02x}"))
62                .collect::<String>()
63        ))
64    }
65    pub fn as_str(&self) -> &str {
66        &self.0
67    }
68}
69
70#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
71#[serde(try_from = "String")]
72pub struct WirePath(String);
73impl TryFrom<String> for WirePath {
74    type Error = String;
75    fn try_from(value: String) -> Result<Self, String> {
76        require(
77            !value.is_empty() && value.len() <= 1024,
78            "invalid wire path length",
79        )?;
80        for component in value.split('/') {
81            require(
82                !component.is_empty()
83                    && component != "."
84                    && component != ".."
85                    && !component.starts_with(' ')
86                    && component
87                        .bytes()
88                        .all(|b| b.is_ascii_alphanumeric() || b" _.-".contains(&b))
89                    && !component.ends_with(['.', ' ']),
90                "invalid wire path component",
91            )?;
92            let base = component
93                .split('.')
94                .next()
95                .unwrap_or("")
96                .to_ascii_uppercase();
97            require(
98                !["CON", "PRN", "AUX", "NUL"].contains(&base.as_str())
99                    && !(base.len() == 4
100                        && (base.starts_with("COM") || base.starts_with("LPT"))
101                        && matches!(base.as_bytes()[3], b'1'..=b'9')),
102                "reserved wire path component",
103            )?;
104        }
105        Ok(Self(value))
106    }
107}
108impl WirePath {
109    pub fn as_str(&self) -> &str {
110        &self.0
111    }
112}
113
114/// Reject file/directory prefix conflicts and case aliases at every component,
115/// including distinct files under differently cased directory spellings.
116pub(crate) fn validate_paths<'a>(paths: impl IntoIterator<Item = &'a str>) -> Result<(), String> {
117    let paths: Vec<_> = paths.into_iter().collect();
118    let mut spelling = std::collections::BTreeMap::new();
119    let mut leaves = BTreeSet::new();
120    for path in &paths {
121        require(
122            leaves.insert(path.to_ascii_lowercase()),
123            "duplicate filesystem path",
124        )?;
125        let mut prefix = String::new();
126        for part in path.split('/') {
127            if !prefix.is_empty() {
128                prefix.push('/');
129            }
130            prefix.push_str(part);
131            if let Some(prior) = spelling.insert(prefix.to_ascii_lowercase(), prefix.clone()) {
132                require(prior == prefix, "case-ambiguous filesystem component")?;
133            }
134        }
135    }
136    for path in paths {
137        for (index, _) in path.match_indices('/') {
138            require(
139                !leaves.contains(&path[..index].to_ascii_lowercase()),
140                "file/directory path conflict",
141            )?;
142        }
143    }
144    Ok(())
145}
146
147#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
148#[serde(rename_all = "kebab-case")]
149pub enum Stage {
150    PlanApproval,
151    CommandPermission,
152    MilestoneValidation,
153    FinalGate,
154    Merge,
155}
156
157#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
158#[serde(deny_unknown_fields)]
159pub struct GitObject {
160    pub algorithm: String,
161    pub value: String,
162}
163impl GitObject {
164    fn validate(&self) -> Result<(), String> {
165        let length = match self.algorithm.as_str() {
166            "sha1" => 40,
167            "sha256" => 64,
168            _ => return Err("unknown Git object algorithm".into()),
169        };
170        require(
171            self.value.len() == length
172                && self
173                    .value
174                    .bytes()
175                    .all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b)),
176            "invalid Git object digest",
177        )
178    }
179}
180
181#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
182#[serde(rename_all = "camelCase", deny_unknown_fields)]
183pub struct Binding {
184    pub subject_digest: Digest,
185    pub plan_digest: Digest,
186    pub policy_digest: Digest,
187    pub registration_digest: Digest,
188    pub workspace_id: Id,
189}
190
191#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
192#[serde(untagged)]
193pub enum PeerRequestId {
194    Text(String),
195    Number(u64),
196}
197
198#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
199#[serde(
200    tag = "kind",
201    rename_all = "kebab-case",
202    rename_all_fields = "camelCase",
203    deny_unknown_fields
204)]
205pub enum Subject {
206    Plan {
207        revision: u64,
208        plan_digest: Digest,
209        base_commit: GitObject,
210    },
211    Invocation {
212        run_id: Id,
213        peer_session_id: String,
214        tool_call_id: String,
215        peer_request_id: PeerRequestId,
216        action_digest: Digest,
217        options_digest: Digest,
218        cwd_id: Id,
219    },
220    Milestone {
221        milestone_id: Id,
222        start_commit: GitObject,
223        snapshot_digest: Digest,
224        criteria_digest: Digest,
225    },
226    Deliverable {
227        base_commit: GitObject,
228        snapshot_digest: Digest,
229        feature_receipt_digest: Digest,
230    },
231    Integration {
232        live_base_commit: GitObject,
233        candidate_commit: GitObject,
234        integration_tree: GitObject,
235        snapshot_digest: Digest,
236    },
237}
238impl Subject {
239    pub fn stage(&self) -> Stage {
240        match self {
241            Self::Plan { .. } => Stage::PlanApproval,
242            Self::Invocation { .. } => Stage::CommandPermission,
243            Self::Milestone { .. } => Stage::MilestoneValidation,
244            Self::Deliverable { .. } => Stage::FinalGate,
245            Self::Integration { .. } => Stage::Merge,
246        }
247    }
248    fn validate(&self) -> Result<(), String> {
249        match self {
250            Self::Plan {
251                revision,
252                base_commit,
253                ..
254            } => {
255                require(
256                    (1..=MAX_JSON_INTEGER).contains(revision),
257                    "invalid plan revision",
258                )?;
259                base_commit.validate()
260            }
261            Self::Invocation {
262                peer_session_id,
263                tool_call_id,
264                peer_request_id,
265                ..
266            } => {
267                bounded_text(peer_session_id, 256)?;
268                bounded_text(tool_call_id, 256)?;
269                match peer_request_id {
270                    PeerRequestId::Text(text) => bounded_text(text, 256),
271                    PeerRequestId::Number(n) => {
272                        require(*n <= MAX_JSON_INTEGER, "invalid peer request id")
273                    }
274                }
275            }
276            Self::Milestone { start_commit, .. } => start_commit.validate(),
277            Self::Deliverable { base_commit, .. } => base_commit.validate(),
278            Self::Integration {
279                live_base_commit,
280                candidate_commit,
281                integration_tree,
282                ..
283            } => {
284                live_base_commit.validate()?;
285                candidate_commit.validate()?;
286                integration_tree.validate()
287            }
288        }
289    }
290}
291
292#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
293#[serde(deny_unknown_fields)]
294pub struct ArtifactRef {
295    pub path: WirePath,
296    pub digest: Digest,
297    pub bytes: u64,
298}
299impl ArtifactRef {
300    fn validate(&self, input: bool) -> Result<(), String> {
301        require(
302            self.bytes <= 64 * 1024 * 1024,
303            "artifact exceeds wire byte limit",
304        )?;
305        require(
306            if input {
307                self.path.as_str().starts_with("inputs/")
308            } else {
309                !self.path.as_str().starts_with("outputs/")
310            },
311            "artifact path has the wrong input/output root",
312        )
313    }
314}
315
316#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
317#[serde(rename_all = "camelCase", deny_unknown_fields)]
318pub struct Limits {
319    pub wall_time_ms: u64,
320    pub write_time_ms: u64,
321    pub max_frame_bytes: u64,
322    pub max_stdout_bytes: u64,
323    pub max_stderr_bytes: u64,
324    pub max_artifact_bytes: u64,
325    pub max_artifacts: u64,
326}
327impl Limits {
328    fn validate(&self) -> Result<(), String> {
329        require(
330            (1..=3_600_000).contains(&self.wall_time_ms)
331                && (1..=60_000).contains(&self.write_time_ms),
332            "invalid gate time limits",
333        )?;
334        require(
335            [
336                self.max_frame_bytes,
337                self.max_stdout_bytes,
338                self.max_stderr_bytes,
339            ]
340            .iter()
341            .all(|n| (1..=1_048_576).contains(n)),
342            "invalid gate stream limits",
343        )?;
344        require(
345            (1..=67_108_864).contains(&self.max_artifact_bytes) && self.max_artifacts <= 128,
346            "invalid gate artifact limits",
347        )
348    }
349}
350
351#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
352#[serde(rename_all = "camelCase", deny_unknown_fields)]
353pub struct Parameters {
354    pub schema_version: u64,
355    pub evaluation_id: Id,
356    pub attempt_id: Id,
357    pub gate_id: Id,
358    pub mission_id: Id,
359    pub binding: Binding,
360    pub stage: Stage,
361    pub subject: Subject,
362    pub evidence: ArtifactRef,
363    pub deadline: String,
364    pub limits: Limits,
365}
366#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
367#[serde(deny_unknown_fields)]
368pub struct Request {
369    pub jsonrpc: String,
370    pub id: Id,
371    pub method: String,
372    pub params: Parameters,
373}
374impl Request {
375    pub fn from_bytes(bytes: &[u8]) -> Result<Self, String> {
376        require(bytes.len() <= 1_048_576, "request exceeds frame limit")?;
377        let request: Self = decode(bytes)?;
378        request.validate()?;
379        Ok(request)
380    }
381    pub fn validate(&self) -> Result<(), String> {
382        require(
383            self.jsonrpc == "2.0"
384                && self.method == "gate/evaluate"
385                && self.params.schema_version == 1,
386            "unsupported gate request protocol/version",
387        )?;
388        require(
389            self.id == self.params.attempt_id,
390            "JSON-RPC id must equal attemptId",
391        )?;
392        require(
393            self.params.stage == self.params.subject.stage(),
394            "stage/subject mismatch",
395        )?;
396        self.params.subject.validate()?;
397        self.params.evidence.validate(true)?;
398        self.params.limits.validate()?;
399        require(
400            self.params.deadline.len() == 20
401                && self
402                    .params
403                    .deadline
404                    .bytes()
405                    .enumerate()
406                    .all(|(i, b)| match i {
407                        4 | 7 => b == b'-',
408                        10 => b == b'T',
409                        13 | 16 => b == b':',
410                        19 => b == b'Z',
411                        _ => b.is_ascii_digit(),
412                    }),
413            "deadline must have UTC second precision",
414        )?;
415        chrono::DateTime::parse_from_rfc3339(&self.params.deadline)
416            .map_err(|_| "invalid gate deadline".to_string())?;
417        Ok(())
418    }
419}
420
421#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
422#[serde(rename_all = "kebab-case")]
423pub enum ArtifactRole {
424    Scope,
425    Criteria,
426    Subject,
427    Policy,
428    Registration,
429    Plan,
430    Action,
431    PermissionOptions,
432    FeatureReceipts,
433    Diff,
434    SnapshotInventory,
435    Source,
436    CheckReceipt,
437    PriorFinding,
438    Context,
439}
440#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
441#[serde(rename_all = "kebab-case")]
442pub enum ProducerKind {
443    Engine,
444    IndependentChecker,
445    Worker,
446}
447#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
448#[serde(rename_all = "camelCase", deny_unknown_fields)]
449pub struct Producer {
450    pub kind: ProducerKind,
451    #[serde(
452        default,
453        deserialize_with = "present",
454        skip_serializing_if = "Option::is_none"
455    )]
456    pub run_id: Option<Id>,
457}
458#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
459#[serde(deny_unknown_fields)]
460pub struct InputArtifact {
461    pub id: Id,
462    pub role: ArtifactRole,
463    pub content: ArtifactRef,
464    pub producer: Producer,
465}
466#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
467#[serde(deny_unknown_fields)]
468pub struct LogRange {
469    pub first: u64,
470    pub last: u64,
471}
472#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
473#[serde(rename_all = "camelCase", deny_unknown_fields)]
474pub struct Manifest {
475    pub schema_version: u64,
476    pub mission_id: Id,
477    pub binding: Binding,
478    pub artifacts: Vec<InputArtifact>,
479    #[serde(
480        default,
481        deserialize_with = "present",
482        skip_serializing_if = "Option::is_none"
483    )]
484    pub source_log_range: Option<LogRange>,
485}
486impl Manifest {
487    pub fn from_bytes(bytes: &[u8]) -> Result<Self, String> {
488        require(bytes.len() <= 67_108_864, "manifest exceeds byte limit")?;
489        let manifest: Self = decode(bytes)?;
490        require(
491            manifest.schema_version == 1
492                && !manifest.artifacts.is_empty()
493                && manifest.artifacts.len() <= 100_000,
494            "unsupported or empty evidence manifest",
495        )?;
496        if let Some(range) = &manifest.source_log_range {
497            require(
498                range.first <= range.last && range.last <= MAX_JSON_INTEGER,
499                "invalid source log range",
500            )?;
501        }
502        let mut ids = BTreeSet::new();
503        let mut paths = BTreeSet::new();
504        for artifact in &manifest.artifacts {
505            artifact.content.validate(true)?;
506            require(
507                ids.insert(&artifact.id)
508                    && paths.insert(artifact.content.path.as_str().to_ascii_lowercase()),
509                "duplicate artifact ID or case-ambiguous path",
510            )?;
511        }
512        require(
513            manifest.artifacts.windows(2).all(|a| a[0].id < a[1].id),
514            "manifest artifacts must be sorted by ID",
515        )?;
516        validate_paths(manifest.artifacts.iter().map(|a| a.content.path.as_str()))?;
517        Ok(manifest)
518    }
519}
520
521#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
522#[serde(rename_all = "camelCase", deny_unknown_fields)]
523pub struct Anchor {
524    pub artifact_id: Id,
525    pub digest: Digest,
526    #[serde(
527        default,
528        deserialize_with = "present",
529        skip_serializing_if = "Option::is_none"
530    )]
531    pub line_start: Option<u64>,
532    #[serde(
533        default,
534        deserialize_with = "present",
535        skip_serializing_if = "Option::is_none"
536    )]
537    pub line_end: Option<u64>,
538}
539#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
540#[serde(rename_all = "lowercase")]
541pub enum Severity {
542    Info,
543    Low,
544    Medium,
545    High,
546    Critical,
547}
548#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
549#[serde(deny_unknown_fields)]
550pub struct Finding {
551    pub id: Id,
552    pub severity: Severity,
553    pub summary: String,
554    pub evidence: Vec<Anchor>,
555}
556#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
557#[serde(rename_all = "lowercase")]
558pub enum Status {
559    Judged,
560    Escalate,
561}
562#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
563#[serde(rename_all = "lowercase")]
564pub enum Verdict {
565    Pass,
566    Fail,
567}
568#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
569#[serde(rename_all = "camelCase", deny_unknown_fields)]
570pub struct EvaluationResult {
571    pub schema_version: u64,
572    pub evaluation_id: Id,
573    pub attempt_id: Id,
574    pub binding: Binding,
575    pub evidence_digest: Digest,
576    pub status: Status,
577    #[serde(
578        default,
579        deserialize_with = "present",
580        skip_serializing_if = "Option::is_none"
581    )]
582    pub verdict: Option<Verdict>,
583    pub rationale: String,
584    pub artifacts: Vec<ArtifactRef>,
585    #[serde(
586        default,
587        deserialize_with = "present",
588        skip_serializing_if = "Option::is_none"
589    )]
590    pub findings: Option<Vec<Finding>>,
591    #[serde(
592        default,
593        deserialize_with = "present",
594        skip_serializing_if = "Option::is_none"
595    )]
596    pub confidence: Option<f64>,
597}
598impl EvaluationResult {
599    fn validate(&self) -> Result<(), String> {
600        require(
601            self.schema_version == 1 && ((self.status == Status::Judged) == self.verdict.is_some()),
602            "invalid judged/escalate result",
603        )?;
604        bounded_text(&self.rationale, 8192)?;
605        require(
606            self.artifacts.len() <= 128 && self.findings.as_ref().is_none_or(|f| f.len() <= 128),
607            "too many result artifacts/findings",
608        )?;
609        let mut paths = BTreeSet::new();
610        for artifact in &self.artifacts {
611            artifact.validate(false)?;
612            require(
613                paths.insert(artifact.path.as_str().to_ascii_lowercase()),
614                "duplicate output artifact path",
615            )?;
616        }
617        validate_paths(self.artifacts.iter().map(|a| a.path.as_str()))?;
618        if let Some(confidence) = self.confidence {
619            require(
620                confidence.is_finite() && (0.0..=1.0).contains(&confidence),
621                "invalid confidence",
622            )?;
623        }
624        let mut ids = BTreeSet::new();
625        for finding in self.findings.iter().flatten() {
626            require(ids.insert(&finding.id), "duplicate finding ID")?;
627            bounded_text(&finding.summary, 4096)?;
628            require(
629                !finding.evidence.is_empty() && finding.evidence.len() <= 32,
630                "finding requires bounded evidence anchors",
631            )?;
632            for anchor in &finding.evidence {
633                match (anchor.line_start, anchor.line_end) {
634                    (None, None) => {}
635                    (Some(start), Some(end)) => require(
636                        start >= 1 && start <= end && end <= MAX_JSON_INTEGER,
637                        "invalid finding line range",
638                    )?,
639                    _ => return Err("finding line bounds must be paired".into()),
640                }
641            }
642        }
643        Ok(())
644    }
645}
646#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
647#[serde(rename_all = "camelCase", deny_unknown_fields)]
648pub struct ErrorData {
649    #[serde(
650        default,
651        deserialize_with = "present",
652        skip_serializing_if = "Option::is_none"
653    )]
654    pub evaluation_id: Option<Id>,
655    #[serde(
656        default,
657        deserialize_with = "present",
658        skip_serializing_if = "Option::is_none"
659    )]
660    pub attempt_id: Option<Id>,
661}
662#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
663#[serde(deny_unknown_fields)]
664pub struct RpcError {
665    pub code: i64,
666    pub message: String,
667    #[serde(
668        default,
669        deserialize_with = "present",
670        skip_serializing_if = "Option::is_none"
671    )]
672    pub data: Option<ErrorData>,
673}
674#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
675#[serde(deny_unknown_fields)]
676pub struct SuccessResponse {
677    pub jsonrpc: String,
678    pub id: Id,
679    pub result: EvaluationResult,
680}
681#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
682#[serde(deny_unknown_fields)]
683pub struct ErrorResponse {
684    pub jsonrpc: String,
685    #[serde(deserialize_with = "required_nullable")]
686    pub id: Option<Id>,
687    pub error: RpcError,
688}
689#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
690#[serde(untagged)]
691pub enum Response {
692    Result(Box<SuccessResponse>),
693    Error(ErrorResponse),
694}
695impl Response {
696    pub fn from_bytes(bytes: &[u8]) -> Result<Self, String> {
697        require(bytes.len() <= 1_048_576, "response exceeds frame limit")?;
698        let response: Self = decode(bytes)?;
699        match &response {
700            Self::Result(r) => {
701                require(r.jsonrpc == "2.0", "invalid JSON-RPC version")?;
702                r.result.validate()?;
703            }
704            Self::Error(r) => {
705                require(
706                    r.jsonrpc == "2.0"
707                        && [
708                            -32700, -32600, -32601, -32602, -32603, 1001, 1002, 1003, 1004,
709                        ]
710                        .contains(&r.error.code),
711                    "invalid gate RPC error",
712                )?;
713                require(
714                    r.id.is_some() || [-32700, -32600].contains(&r.error.code),
715                    "null ID only identifies a malformed request error",
716                )?;
717                bounded_text(&r.error.message, 4096)?;
718            }
719        }
720        Ok(response)
721    }
722    pub fn correlate(&self, request: &Request) -> Result<(), String> {
723        match self {
724            Self::Result(r) => require(
725                r.id == request.id
726                    && r.result.evaluation_id == request.params.evaluation_id
727                    && r.result.attempt_id == request.params.attempt_id
728                    && r.result.binding == request.params.binding
729                    && r.result.evidence_digest == request.params.evidence.digest,
730                "gate response correlation or evidence binding mismatch",
731            ),
732            Self::Error(_) => Err("evaluator could not judge the evidence".into()),
733        }
734    }
735}
736
737fn bounded_text(text: &str, max: usize) -> Result<(), String> {
738    require(
739        !text.is_empty() && text.chars().count() <= max,
740        "invalid bounded text length",
741    )
742}
743pub(super) fn decode<T: serde::de::DeserializeOwned>(bytes: &[u8]) -> Result<T, String> {
744    let mut value =
745        crate::strict_json::parse(bytes).map_err(|e| format!("invalid gate JSON: {e}"))?;
746    normalize_integral_numbers(&mut value);
747    serde_json::from_value(value).map_err(|e| format!("invalid gate wire shape: {e}"))
748}
749
750// JSON Schema integers include 1.0 and 1e0. Normalize only exact integers
751// inside the contract's safe range; hashes still cover original input bytes.
752fn normalize_integral_numbers(value: &mut serde_json::Value) {
753    match value {
754        serde_json::Value::Array(values) => values.iter_mut().for_each(normalize_integral_numbers),
755        serde_json::Value::Object(values) => {
756            values.values_mut().for_each(normalize_integral_numbers)
757        }
758        serde_json::Value::Number(number) if number.is_f64() => {
759            if let Some(n) = number
760                .as_f64()
761                .filter(|n| n.fract() == 0.0 && n.abs() <= MAX_JSON_INTEGER as f64)
762            {
763                *value = serde_json::Value::from(n as i64);
764            }
765        }
766        _ => {}
767    }
768}
769
770// Missing optional fields are allowed; explicit null is not a schema value.
771fn present<'de, D: serde::Deserializer<'de>, T: Deserialize<'de>>(
772    deserializer: D,
773) -> Result<Option<T>, D::Error> {
774    T::deserialize(deserializer).map(Some)
775}
776fn required_nullable<'de, D: serde::Deserializer<'de>>(
777    deserializer: D,
778) -> Result<Option<Id>, D::Error> {
779    Option::<Id>::deserialize(deserializer)
780}
781
782#[cfg(test)]
783mod tests {
784    use super::*;
785    use serde_json::{json, Value};
786    fn fixture(stage: &str, file: &str) -> Vec<u8> {
787        std::fs::read(
788            std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
789                .join("schemas/fixtures/gate-v1")
790                .join(stage)
791                .join(file),
792        )
793        .unwrap()
794    }
795    #[test]
796    fn gate_eval_v1_all_shipped_stage_fixtures_decode_and_correlate() {
797        for stage in [
798            "plan-approval",
799            "command-permission",
800            "milestone-validation",
801            "final-gate",
802            "merge",
803        ] {
804            let request = Request::from_bytes(&fixture(stage, "request.json")).unwrap();
805            let manifest = Manifest::from_bytes(&fixture(stage, "inputs/manifest.json")).unwrap();
806            assert_eq!(manifest.binding, request.params.binding);
807            for verdict in ["pass.json", "fail.json", "escalate.json"] {
808                let response = Response::from_bytes(&fixture(stage, verdict)).unwrap();
809                response.correlate(&request).unwrap();
810            }
811        }
812    }
813    #[test]
814    fn gate_eval_v1_rejects_authority_fields_nulls_versions_and_correlations() {
815        let original: Value = serde_json::from_slice(&fixture("final-gate", "pass.json")).unwrap();
816        let request = Request::from_bytes(&fixture("final-gate", "request.json")).unwrap();
817        for (key, value) in [
818            ("actor", json!("human:spoof")),
819            ("verdict", Value::Null),
820            ("findings", Value::Null),
821            ("confidence", Value::Null),
822            ("schemaVersion", json!(2)),
823        ] {
824            let mut invalid = original.clone();
825            invalid["result"][key] = value;
826            assert!(
827                Response::from_bytes(&serde_json::to_vec(&invalid).unwrap()).is_err(),
828                "{key}"
829            );
830        }
831        let mut wrong = original.clone();
832        wrong["result"]["attemptId"] = json!("another-attempt");
833        assert!(Response::from_bytes(&serde_json::to_vec(&wrong).unwrap())
834            .unwrap()
835            .correlate(&request)
836            .is_err());
837        assert!(Response::from_bytes(
838            br#"{"jsonrpc":"2.0","error":{"code":-32700,"message":"bad"}}"#
839        )
840        .is_err());
841        assert!(Response::from_bytes(
842            br#"{"jsonrpc":"2.0","id":null,"error":{"code":-32700,"message":"bad"}}"#
843        )
844        .is_ok());
845        assert!(Response::from_bytes(
846            br#"{"jsonrpc":"2.0","id":null,"error":{"code":1004,"message":"bad"}}"#
847        )
848        .is_err());
849        let mut numeric_request: Value =
850            serde_json::from_slice(&fixture("final-gate", "request.json")).unwrap();
851        numeric_request["params"]["schemaVersion"] = json!(1.0);
852        assert!(Request::from_bytes(&serde_json::to_vec(&numeric_request).unwrap()).is_ok());
853        let mut wrong_request: Value =
854            serde_json::from_slice(&fixture("final-gate", "request.json")).unwrap();
855        wrong_request["params"]["stage"] = json!("merge");
856        assert!(Request::from_bytes(&serde_json::to_vec(&wrong_request).unwrap()).is_err());
857    }
858    #[test]
859    fn gate_eval_v1_rejects_aliases_at_parent_components_and_output_prefixes() {
860        assert!(validate_paths(["inputs/A/one", "inputs/a/two"]).is_err());
861        assert!(validate_paths(["inputs/file", "inputs/file/child"]).is_err());
862        let mut result: Value =
863            serde_json::from_slice(&fixture("final-gate", "pass.json")).unwrap();
864        result["result"]["artifacts"] =
865            json!([{"path":"outputs/claim.txt","digest":Digest::of(b""),"bytes":0}]);
866        assert!(Response::from_bytes(&serde_json::to_vec(&result).unwrap()).is_err());
867    }
868    #[test]
869    fn gate_eval_v1_rejects_ambiguous_paths_and_duplicate_json() {
870        for path in [
871            "../x",
872            "inputs/../x",
873            "/absolute",
874            "inputs/CON.txt",
875            "inputs/nul",
876            "inputs/a.",
877            "a\\b",
878            "inputs/a:b",
879            "inputs/a\n",
880        ] {
881            assert!(WirePath::try_from(path.to_string()).is_err(), "{path}");
882        }
883        assert_eq!(
884            Digest::of(b"abc").as_str(),
885            "sha256:ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad"
886        );
887        assert!(Response::from_bytes(br#"{"jsonrpc":"2.0","jsonrpc":"2.0","id":null,"error":{"code":-32700,"message":"bad"}}"#).is_err());
888    }
889}