Skip to main content

runx_contracts/
operational_proposal.rs

1//! Operational proposal contract: reviewable handoffs over existing actions.
2// rust-style-allow: large-file because the proposal schema, the open reference
3// type vocabulary, the human-gate and outcome shapes, and the RunxSchema
4// reflection together form one cross-language wire surface.
5use serde::{Deserialize, Serialize};
6use serde_json::{Value, json};
7
8use crate::schema::{
9    Identity, IsoDateTime, NonEmptyString, Property, RunxSchema, deserialize_false_bool,
10    deserialize_true_bool, object_schema,
11};
12use crate::{JsonObject, Reference, ReferenceLink};
13
14pub const OPERATIONAL_PROPOSAL_SCHEMA: &str = "runx.operational_proposal.v1";
15
16#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize, RunxSchema)]
17pub enum OperationalProposalSchema {
18    #[serde(rename = "runx.operational_proposal.v1")]
19    V1,
20}
21
22#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize, RunxSchema)]
23#[serde(rename_all = "snake_case")]
24pub enum OperationalProposalRedactionStatus {
25    Redacted,
26    SummaryOnly,
27    Blocked,
28}
29
30#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, RunxSchema)]
31#[serde(deny_unknown_fields)]
32pub struct OperationalProposalRecommendedAction {
33    pub action_intent: NonEmptyString,
34    pub summary: NonEmptyString,
35    pub mutating: bool,
36    #[serde(default, skip_serializing_if = "Vec::is_empty")]
37    pub target_refs: Vec<Reference>,
38}
39
40#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, RunxSchema)]
41#[serde(deny_unknown_fields)]
42pub struct OperationalProposalIdempotency {
43    pub key: NonEmptyString,
44    pub fingerprint: NonEmptyString,
45}
46
47#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
48#[serde(deny_unknown_fields)]
49pub struct OperationalProposalAuthority {
50    #[serde(deserialize_with = "deserialize_true_bool")]
51    pub proposal_only: bool,
52    #[serde(deserialize_with = "deserialize_false_bool")]
53    pub mutation_authority_granted: bool,
54    #[serde(deserialize_with = "deserialize_false_bool")]
55    pub publication_authority_granted: bool,
56    #[serde(deserialize_with = "deserialize_false_bool")]
57    pub final_decision_authority_granted: bool,
58    #[serde(default, skip_serializing_if = "Vec::is_empty")]
59    pub notes: Vec<NonEmptyString>,
60}
61
62impl RunxSchema for OperationalProposalAuthority {
63    fn json_schema() -> Value {
64        object_schema(
65            vec![
66                Property::new("proposal_only", const_bool(true), true),
67                Property::new("mutation_authority_granted", const_bool(false), true),
68                Property::new("publication_authority_granted", const_bool(false), true),
69                Property::new("final_decision_authority_granted", const_bool(false), true),
70                Property::new("notes", Vec::<NonEmptyString>::json_schema(), false),
71            ],
72            true,
73            None,
74        )
75    }
76}
77
78#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, RunxSchema)]
79#[serde(deny_unknown_fields)]
80pub struct OperationalProposalHumanGate {
81    pub gate_id: NonEmptyString,
82    pub gate_kind: NonEmptyString,
83    pub required: bool,
84    pub decision: NonEmptyString,
85    pub reason: NonEmptyString,
86}
87
88#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, RunxSchema)]
89#[serde(deny_unknown_fields)]
90pub struct OperationalProposalOutcome {
91    pub observed: bool,
92    pub status: NonEmptyString,
93    pub summary: NonEmptyString,
94    #[serde(skip_serializing_if = "Option::is_none")]
95    pub observed_at: Option<IsoDateTime>,
96    #[serde(default, skip_serializing_if = "Vec::is_empty")]
97    pub refs: Vec<Reference>,
98}
99
100#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
101#[serde(deny_unknown_fields)]
102pub struct OperationalProposal {
103    pub schema: OperationalProposalSchema,
104    pub proposal_id: NonEmptyString,
105    pub proposal_kind: NonEmptyString,
106    pub source_event_id: NonEmptyString,
107    pub idempotency: OperationalProposalIdempotency,
108    pub source_ref: Reference,
109    #[serde(skip_serializing_if = "Option::is_none")]
110    pub source_thread_ref: Option<Reference>,
111    pub hydrated_context_ref: Reference,
112    pub redaction_status: OperationalProposalRedactionStatus,
113    pub decision_summary: NonEmptyString,
114    pub rationale: NonEmptyString,
115    #[serde(default)]
116    pub recommended_actions: Vec<OperationalProposalRecommendedAction>,
117    #[serde(default)]
118    pub evidence_refs: Vec<Reference>,
119    #[serde(default)]
120    pub artifact_refs: Vec<Reference>,
121    #[serde(default)]
122    pub receipt_refs: Vec<Reference>,
123    #[serde(default)]
124    pub story_refs: Vec<Reference>,
125    #[serde(default)]
126    pub result_refs: Vec<ReferenceLink>,
127    #[serde(default)]
128    pub publication_refs: Vec<ReferenceLink>,
129    pub owner_route_id: NonEmptyString,
130    pub confidence: f64,
131    #[serde(default)]
132    pub risks: Vec<NonEmptyString>,
133    #[serde(default)]
134    pub caveats: Vec<NonEmptyString>,
135    #[serde(default)]
136    pub missing_context: Vec<NonEmptyString>,
137    pub authority: OperationalProposalAuthority,
138    #[serde(default)]
139    pub human_gates: Vec<OperationalProposalHumanGate>,
140    #[serde(default)]
141    pub allowed_next_actions: Vec<NonEmptyString>,
142    #[serde(skip_serializing_if = "Option::is_none")]
143    pub final_outcome: Option<OperationalProposalOutcome>,
144    pub public_summary: NonEmptyString,
145    /// Product-neutral extensions. `runx.escalation` carries escalation
146    /// severity and urgency without adding provider-specific core fields.
147    #[serde(skip_serializing_if = "Option::is_none")]
148    pub extensions: Option<JsonObject>,
149}
150
151impl RunxSchema for OperationalProposal {
152    // rust-style-allow: long-function - the public proposal schema is a single
153    // closed contract document, and keeping its field list contiguous makes
154    // review against the wire contract less error-prone.
155    fn json_schema() -> Value {
156        object_schema(
157            vec![
158                Property::new("schema", OperationalProposalSchema::json_schema(), true),
159                Property::new("proposal_id", id_schema(), true),
160                Property::new("proposal_kind", NonEmptyString::json_schema(), true),
161                Property::new("source_event_id", id_schema(), true),
162                Property::new(
163                    "idempotency",
164                    OperationalProposalIdempotency::json_schema(),
165                    true,
166                ),
167                Property::new("source_ref", Reference::json_schema(), true),
168                Property::new("source_thread_ref", Reference::json_schema(), false),
169                Property::new("hydrated_context_ref", Reference::json_schema(), true),
170                Property::new(
171                    "redaction_status",
172                    OperationalProposalRedactionStatus::json_schema(),
173                    true,
174                ),
175                Property::new("decision_summary", NonEmptyString::json_schema(), true),
176                Property::new("rationale", NonEmptyString::json_schema(), true),
177                Property::new(
178                    "recommended_actions",
179                    Vec::<OperationalProposalRecommendedAction>::json_schema(),
180                    false,
181                ),
182                Property::new("evidence_refs", Vec::<Reference>::json_schema(), false),
183                Property::new("artifact_refs", Vec::<Reference>::json_schema(), false),
184                Property::new("receipt_refs", Vec::<Reference>::json_schema(), false),
185                Property::new("story_refs", Vec::<Reference>::json_schema(), false),
186                Property::new("result_refs", Vec::<ReferenceLink>::json_schema(), false),
187                Property::new(
188                    "publication_refs",
189                    Vec::<ReferenceLink>::json_schema(),
190                    false,
191                ),
192                Property::new("owner_route_id", id_schema(), true),
193                Property::new("confidence", confidence_schema(), true),
194                Property::new("risks", Vec::<NonEmptyString>::json_schema(), false),
195                Property::new("caveats", Vec::<NonEmptyString>::json_schema(), false),
196                Property::new(
197                    "missing_context",
198                    Vec::<NonEmptyString>::json_schema(),
199                    false,
200                ),
201                Property::new(
202                    "authority",
203                    OperationalProposalAuthority::json_schema(),
204                    true,
205                ),
206                Property::new(
207                    "human_gates",
208                    Vec::<OperationalProposalHumanGate>::json_schema(),
209                    false,
210                ),
211                Property::new(
212                    "allowed_next_actions",
213                    Vec::<NonEmptyString>::json_schema(),
214                    false,
215                ),
216                Property::new(
217                    "final_outcome",
218                    OperationalProposalOutcome::json_schema(),
219                    false,
220                ),
221                Property::new("public_summary", NonEmptyString::json_schema(), true),
222                Property::new("extensions", JsonObject::json_schema(), false),
223            ],
224            true,
225            Some(Identity::Runx {
226                logical: OPERATIONAL_PROPOSAL_SCHEMA,
227                url: None,
228            }),
229        )
230    }
231}
232
233fn id_schema() -> Value {
234    json!({
235        "minLength": 1,
236        "pattern": "^[A-Za-z0-9_.:-]+$",
237        "type": "string"
238    })
239}
240
241fn confidence_schema() -> Value {
242    json!({
243        "maximum": 1,
244        "minimum": 0,
245        "type": "number"
246    })
247}
248
249fn const_bool(value: bool) -> Value {
250    json!({ "const": value, "type": "boolean" })
251}