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