Skip to content

Commit 45ab0c3

Browse files
author
Michael Eichelbeck
committed
feat(v2): freeze native runtime contracts
1 parent e147f2d commit 45ab0c3

3 files changed

Lines changed: 365 additions & 2 deletions

File tree

zeroshot-rust/src/lib.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ pub mod issue_provider;
1313
mod native_admission;
1414
pub mod native_credentials;
1515
pub mod native_settings;
16+
pub mod native_v2_contract;
1617
pub mod product_errors;
1718
mod provider_value;
1819
pub mod required_proof;
Lines changed: 362 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,362 @@
1+
//! Minimal, secret-free composition contracts for the native-v2 engine.
2+
//!
3+
//! `GraphSpec` remains the graph language. This module only binds executable graph leaves to one
4+
//! graph-wide harness/provider lane and defines the neutral values exchanged by admission, the
5+
//! reducer, the runner, and the run ledger. It deliberately contains no admission or execution
6+
//! policy.
7+
8+
use std::collections::{BTreeMap, BTreeSet};
9+
use std::fmt;
10+
use std::num::NonZeroU64;
11+
12+
use openengine_cluster_protocol::{
13+
CompiledGraphIr, GraphSpec, IdempotencyKey, NodeName, RunId, WorkerOutcome, WorkerRef,
14+
};
15+
use serde::{Deserialize, Serialize};
16+
use serde_json::Value;
17+
use thiserror::Error;
18+
19+
use crate::execution::SessionScope;
20+
use crate::worker_catalog::{ModelId, ReasoningEffort};
21+
22+
#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
23+
#[serde(rename_all = "snake_case")]
24+
pub enum CodexProvider {
25+
#[serde(rename = "openai")]
26+
OpenAi,
27+
#[serde(rename = "openrouter")]
28+
OpenRouter,
29+
}
30+
31+
#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
32+
#[serde(rename_all = "snake_case")]
33+
pub enum ClaudeProvider {
34+
Anthropic,
35+
#[serde(rename = "openrouter")]
36+
OpenRouter,
37+
}
38+
39+
/// One harness/provider lane for the entire graph.
40+
///
41+
/// The tagged variants make unsupported pairings unrepresentable without a second validation
42+
/// table: Codex supports OpenAI/OpenRouter and Claude supports Anthropic/OpenRouter.
43+
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
44+
#[serde(deny_unknown_fields, tag = "harness", rename_all = "snake_case")]
45+
pub enum RuntimePlan {
46+
Codex {
47+
provider: CodexProvider,
48+
nodes: BTreeMap<NodeName, NodeRuntimeBinding>,
49+
},
50+
Claude {
51+
provider: ClaudeProvider,
52+
nodes: BTreeMap<NodeName, NodeRuntimeBinding>,
53+
},
54+
}
55+
56+
impl RuntimePlan {
57+
#[must_use]
58+
pub fn nodes(&self) -> &BTreeMap<NodeName, NodeRuntimeBinding> {
59+
match self {
60+
Self::Codex { nodes, .. } | Self::Claude { nodes, .. } => nodes,
61+
}
62+
}
63+
}
64+
65+
/// Runtime configuration for an executable graph leaf.
66+
///
67+
/// Environment values are intentionally absent. The controller resolves only these declared
68+
/// names immediately before invocation. Git delivery is graph-visible but is not an agent session.
69+
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
70+
#[serde(deny_unknown_fields, tag = "kind", rename_all = "snake_case")]
71+
pub enum NodeRuntimeBinding {
72+
Agent {
73+
model: ModelId,
74+
#[serde(default, skip_serializing_if = "Option::is_none")]
75+
effort: Option<ReasoningEffort>,
76+
#[serde(
77+
default,
78+
rename = "sessionScope",
79+
skip_serializing_if = "is_execution_scope"
80+
)]
81+
session_scope: SessionScope,
82+
#[serde(default, skip_serializing_if = "BTreeSet::is_empty")]
83+
env: BTreeSet<EnvironmentVariableName>,
84+
},
85+
GitDelivery {
86+
#[serde(default, skip_serializing_if = "BTreeSet::is_empty")]
87+
env: BTreeSet<EnvironmentVariableName>,
88+
},
89+
}
90+
91+
fn is_execution_scope(scope: &SessionScope) -> bool {
92+
*scope == SessionScope::Execution
93+
}
94+
95+
impl NodeRuntimeBinding {
96+
#[must_use]
97+
pub fn declared_environment(&self) -> &BTreeSet<EnvironmentVariableName> {
98+
match self {
99+
Self::Agent { env, .. } | Self::GitDelivery { env } => env,
100+
}
101+
}
102+
}
103+
104+
#[derive(Clone, Debug, Eq, Error, PartialEq)]
105+
pub enum EnvironmentVariableNameError {
106+
#[error("environment variable name must match [A-Za-z_][A-Za-z0-9_]*")]
107+
Invalid,
108+
#[error("environment variable name must be at most 128 bytes")]
109+
TooLong,
110+
}
111+
112+
#[derive(Clone, Debug, Deserialize, Eq, Hash, Ord, PartialEq, PartialOrd, Serialize)]
113+
#[serde(try_from = "String")]
114+
pub struct EnvironmentVariableName(String);
115+
116+
impl EnvironmentVariableName {
117+
pub fn new(value: impl Into<String>) -> Result<Self, EnvironmentVariableNameError> {
118+
let value = value.into();
119+
if value.len() > 128 {
120+
return Err(EnvironmentVariableNameError::TooLong);
121+
}
122+
let mut bytes = value.bytes();
123+
let valid_first = bytes
124+
.next()
125+
.is_some_and(|byte| byte.is_ascii_alphabetic() || byte == b'_');
126+
if !valid_first || !bytes.all(|byte| byte.is_ascii_alphanumeric() || byte == b'_') {
127+
return Err(EnvironmentVariableNameError::Invalid);
128+
}
129+
Ok(Self(value))
130+
}
131+
132+
#[must_use]
133+
pub fn as_str(&self) -> &str {
134+
&self.0
135+
}
136+
}
137+
138+
impl TryFrom<String> for EnvironmentVariableName {
139+
type Error = EnvironmentVariableNameError;
140+
141+
fn try_from(value: String) -> Result<Self, Self::Error> {
142+
Self::new(value)
143+
}
144+
}
145+
146+
impl fmt::Display for EnvironmentVariableName {
147+
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
148+
formatter.write_str(&self.0)
149+
}
150+
}
151+
152+
/// The immutable, secret-free request admitted by a selected target.
153+
///
154+
/// Target selection is transport/CLI routing and is therefore not duplicated in this payload.
155+
#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
156+
#[serde(deny_unknown_fields, rename_all = "camelCase")]
157+
pub struct RunSubmission {
158+
pub graph: GraphSpec,
159+
pub initial_input: Value,
160+
pub runtime: RuntimePlan,
161+
#[serde(default)]
162+
pub ship: bool,
163+
pub submission_key: IdempotencyKey,
164+
}
165+
166+
/// Admission's secret-free output. The compiler promotes the unchanged `GraphSpec` to the existing
167+
/// verified `CompiledGraphIr`; later stages never execute raw graph syntax.
168+
#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
169+
#[serde(deny_unknown_fields, rename_all = "camelCase")]
170+
pub struct AdmittedRun {
171+
pub graph: CompiledGraphIr,
172+
pub initial_input: Value,
173+
pub runtime: RuntimePlan,
174+
pub ship: bool,
175+
}
176+
177+
#[derive(Clone, Copy, Debug, Eq, Error, PartialEq)]
178+
#[error("identity must be greater than zero")]
179+
pub struct IdentityError;
180+
181+
macro_rules! identity_type {
182+
($name:ident) => {
183+
#[derive(
184+
Clone, Copy, Debug, Deserialize, Eq, Hash, Ord, PartialEq, PartialOrd, Serialize,
185+
)]
186+
#[serde(try_from = "u64")]
187+
pub struct $name(NonZeroU64);
188+
189+
impl $name {
190+
pub fn new(value: u64) -> Result<Self, IdentityError> {
191+
NonZeroU64::new(value).map(Self).ok_or(IdentityError)
192+
}
193+
194+
#[must_use]
195+
pub const fn get(self) -> u64 {
196+
self.0.get()
197+
}
198+
}
199+
200+
impl TryFrom<u64> for $name {
201+
type Error = IdentityError;
202+
203+
fn try_from(value: u64) -> Result<Self, Self::Error> {
204+
Self::new(value)
205+
}
206+
}
207+
208+
impl fmt::Display for $name {
209+
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
210+
self.0.fmt(formatter)
211+
}
212+
}
213+
};
214+
}
215+
216+
identity_type!(NodeInstanceId);
217+
identity_type!(ExecutionId);
218+
219+
/// Stable address for one dispatch. A node instance survives loop revisits; an execution does not.
220+
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
221+
#[serde(deny_unknown_fields, rename_all = "camelCase")]
222+
pub struct ExecutionRef {
223+
pub run_id: RunId,
224+
pub node: NodeName,
225+
pub node_instance: NodeInstanceId,
226+
pub execution: ExecutionId,
227+
}
228+
229+
/// Secret-free runner request produced by the reducer/supervisor boundary.
230+
///
231+
/// Workspace access and resolved environment values are runtime capabilities and do not belong in
232+
/// this durable value.
233+
#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
234+
#[serde(deny_unknown_fields, rename_all = "camelCase")]
235+
pub struct NodeInvocation {
236+
pub reference: ExecutionRef,
237+
pub worker: WorkerRef,
238+
pub input: Value,
239+
pub binding: NodeRuntimeBinding,
240+
}
241+
242+
/// Normalized completion returned to the supervisor and safe to append to the run ledger.
243+
#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
244+
#[serde(deny_unknown_fields, rename_all = "camelCase")]
245+
pub struct NodeCompletion {
246+
pub reference: ExecutionRef,
247+
pub outcome: WorkerOutcome,
248+
}
249+
250+
#[cfg(test)]
251+
mod tests {
252+
use super::*;
253+
use serde_json::{json, Value};
254+
255+
fn canonical_submission() -> Value {
256+
json!({
257+
"graph": {
258+
"profile": "openengine.graph.full/v1",
259+
"initialInput": { "kind": "null" },
260+
"policy": { "policy": "policy.native-v2@1", "default": "deny" },
261+
"root": {
262+
"kind": "seq",
263+
"name": "run",
264+
"state": { "kind": "null" },
265+
"children": [
266+
{
267+
"kind": "step",
268+
"name": "worker",
269+
"worker": "agent.worker@1",
270+
"input": { "kind": "null" },
271+
"output": { "kind": "null" },
272+
"inputBindings": [],
273+
"writeBindings": [],
274+
"timeoutMs": 60000,
275+
"attempts": 1
276+
},
277+
{
278+
"kind": "succeed",
279+
"name": "done",
280+
"output": { "kind": "null" },
281+
"bindings": []
282+
}
283+
],
284+
"promotedStatePaths": []
285+
}
286+
},
287+
"initialInput": null,
288+
"runtime": {
289+
"harness": "codex",
290+
"provider": "openai",
291+
"nodes": {
292+
"worker": {
293+
"kind": "agent",
294+
"model": "gpt-5.6",
295+
"effort": "max",
296+
"env": ["GH_TOKEN", "OPENAI_API_KEY"]
297+
}
298+
}
299+
},
300+
"ship": false,
301+
"submissionKey": "submission-1"
302+
})
303+
}
304+
305+
#[test]
306+
fn canonical_submission_round_trips_without_changing_graph_spec() {
307+
let expected = canonical_submission();
308+
let submission: RunSubmission =
309+
serde_json::from_value(expected.clone()).expect("canonical fixture must decode");
310+
311+
let NodeRuntimeBinding::Agent { session_scope, .. } = submission
312+
.runtime
313+
.nodes()
314+
.get(&NodeName::new("worker").unwrap())
315+
.unwrap()
316+
else {
317+
panic!("worker must be an agent binding");
318+
};
319+
assert_eq!(*session_scope, SessionScope::Execution);
320+
assert_eq!(serde_json::to_value(submission).unwrap(), expected);
321+
}
322+
323+
#[test]
324+
fn unsupported_harness_provider_pair_is_rejected_by_shape() {
325+
let mut fixture = canonical_submission();
326+
fixture["runtime"]["provider"] = json!("anthropic");
327+
328+
assert!(serde_json::from_value::<RunSubmission>(fixture).is_err());
329+
}
330+
331+
#[test]
332+
fn claude_openrouter_lane_round_trips() {
333+
let mut expected = canonical_submission();
334+
expected["runtime"]["harness"] = json!("claude");
335+
expected["runtime"]["provider"] = json!("openrouter");
336+
expected["runtime"]["nodes"]["worker"]["model"] = json!("claude-sonnet-5");
337+
338+
let submission: RunSubmission = serde_json::from_value(expected.clone()).unwrap();
339+
assert_eq!(serde_json::to_value(submission).unwrap(), expected);
340+
}
341+
342+
#[test]
343+
fn environment_values_and_graph_runtime_fields_are_rejected() {
344+
let mut secret_fixture = canonical_submission();
345+
secret_fixture["runtime"]["nodes"]["worker"]["env"] = json!({ "OPENAI_API_KEY": "secret" });
346+
assert!(serde_json::from_value::<RunSubmission>(secret_fixture).is_err());
347+
348+
let mut graph_fixture = canonical_submission();
349+
graph_fixture["graph"]["root"]["children"][0]["model"] = json!("gpt-5.6");
350+
assert!(serde_json::from_value::<RunSubmission>(graph_fixture).is_err());
351+
}
352+
353+
#[test]
354+
fn environment_names_and_execution_identities_are_bounded() {
355+
assert!(EnvironmentVariableName::new("GH_TOKEN").is_ok());
356+
assert!(EnvironmentVariableName::new("GH-TOKEN").is_err());
357+
assert!(EnvironmentVariableName::new("1TOKEN").is_err());
358+
assert!(NodeInstanceId::new(0).is_err());
359+
assert!(ExecutionId::new(0).is_err());
360+
assert_eq!(ExecutionId::new(7).unwrap().get(), 7);
361+
}
362+
}

zeroshot-rust/src/worker_catalog/policy.rs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
use std::collections::{BTreeMap, BTreeSet};
22

3-
use serde::Serialize;
3+
use serde::{Deserialize, Serialize};
44

55
use crate::execution::{DriverFamilyId, SessionScope};
66
use crate::provider_value::{validate_collection_len, validate_serialized};
@@ -52,7 +52,7 @@ pub enum ModelLevel {
5252
Level3,
5353
}
5454

55-
#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd, Serialize)]
55+
#[derive(Clone, Copy, Debug, Deserialize, Eq, Hash, Ord, PartialEq, PartialOrd, Serialize)]
5656
#[serde(rename_all = "snake_case")]
5757
pub enum ReasoningEffort {
5858
Low,

0 commit comments

Comments
 (0)