Skip to main content

runx_contracts/
operational_policy.rs

1//! Operational policy contracts for governed source, runner, target, and owner routing.
2//
3// Type definitions live here; the validation, admission, and readback projection
4// logic lives in the private `evaluate` submodule.
5// rust-style-allow: large-file because the operational policy schema, rules,
6// and decision shapes form one cross-language wire surface.
7use std::collections::BTreeMap;
8use std::fmt;
9
10use serde::{Deserialize, Serialize};
11use serde_json::{Value, json};
12
13use crate::JsonValue;
14use crate::schema::{Identity, IsoDateTime, NonEmptyString, Property, RunxSchema, object_schema};
15
16mod evaluate;
17
18pub use evaluate::{
19    admit_operational_policy_request, lint_operational_policy_contract,
20    project_operational_policy_readback, validate_operational_policy_contract,
21    validate_operational_policy_semantics,
22};
23
24#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize, RunxSchema)]
25pub enum OperationalPolicySchema {
26    #[serde(rename = "runx.operational_policy.v1")]
27    V1,
28}
29
30impl fmt::Display for OperationalPolicySchema {
31    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
32        formatter.write_str("runx.operational_policy.v1")
33    }
34}
35
36#[derive(
37    Clone, Copy, Debug, Eq, PartialEq, Ord, PartialOrd, Serialize, Deserialize, RunxSchema,
38)]
39#[serde(rename_all = "kebab-case")]
40pub enum OperationalPolicyAction {
41    ReplyOnly,
42    IssueIntake,
43    WorkPlan,
44    IssueToPr,
45    ManualReview,
46    PrReview,
47    PrFixUp,
48    MergeAssist,
49}
50
51impl fmt::Display for OperationalPolicyAction {
52    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
53        formatter.write_str(action_name(*self))
54    }
55}
56
57/// Canonical source provider identifiers for operational policies. Documented
58/// for discoverability; the wire form is an open `NonEmptyString` so adapters
59/// implementing source ingestion can publish their own identifier without a
60/// contract edit.
61pub mod operational_policy_source_provider {
62    /// Slack workspaces and threads.
63    pub const SLACK: &str = "slack";
64    /// Sentry issue/event streams.
65    pub const SENTRY: &str = "sentry";
66    /// GitHub issues and pull requests.
67    pub const GITHUB: &str = "github";
68    /// Files on a workspace volume.
69    pub const FILE: &str = "file";
70    /// Generic HTTP API source.
71    pub const API: &str = "api";
72}
73
74/// Canonical runner kind identifiers for operational policies. The wire form
75/// is an open `NonEmptyString`; adapters that schedule work on a new substrate
76/// can publish their own identifier without a contract edit.
77pub mod operational_policy_runner_kind {
78    /// In-process local runner.
79    pub const LOCAL: &str = "local";
80    /// GitHub Actions hosted runner.
81    pub const GITHUB_ACTIONS: &str = "github-actions";
82    /// Aster operator runner.
83    pub const ASTER: &str = "aster";
84}
85
86#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize, RunxSchema)]
87#[serde(rename_all = "lowercase")]
88pub enum OperationalPolicyRunnerState {
89    Available,
90    Disabled,
91    Maintenance,
92}
93
94impl fmt::Display for OperationalPolicyRunnerState {
95    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
96        formatter.write_str(match self {
97            Self::Available => "available",
98            Self::Disabled => "disabled",
99            Self::Maintenance => "maintenance",
100        })
101    }
102}
103
104#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize, RunxSchema)]
105#[serde(rename_all = "snake_case")]
106pub enum OperationalPolicyDedupeStrategy {
107    SourceFingerprint,
108    ProviderSearch,
109    Branch,
110}
111
112#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize, RunxSchema)]
113#[serde(rename_all = "snake_case")]
114pub enum OperationalPolicyOutcomeCloseMode {
115    Never,
116    WhenVerified,
117    WhenTerminal,
118}
119
120#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize, RunxSchema)]
121#[serde(rename_all = "lowercase")]
122pub enum OperationalPolicyPublishMode {
123    Reply,
124    Comment,
125    None,
126}
127
128impl fmt::Display for OperationalPolicyPublishMode {
129    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
130        formatter.write_str(match self {
131            Self::Reply => "reply",
132            Self::Comment => "comment",
133            Self::None => "none",
134        })
135    }
136}
137
138#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize, RunxSchema)]
139pub enum OperationalPolicyMissingBehavior {
140    #[serde(rename = "fail_closed")]
141    FailClosed,
142}
143
144#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize, RunxSchema)]
145#[serde(rename_all = "lowercase")]
146pub enum OperationalPolicyDuplicateBehavior {
147    Reuse,
148    Comment,
149    Block,
150}
151
152#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
153#[serde(deny_unknown_fields)]
154pub struct OperationalPolicy {
155    pub schema: OperationalPolicySchema,
156    pub schema_version: OperationalPolicySchema,
157    pub policy_id: NonEmptyString,
158    #[serde(skip_serializing_if = "Option::is_none")]
159    pub created_at: Option<IsoDateTime>,
160    pub sources: Vec<OperationalPolicySourceRule>,
161    pub runners: Vec<OperationalPolicyRunnerRule>,
162    pub owner_routes: Vec<OperationalPolicyOwnerRoute>,
163    pub targets: Vec<OperationalPolicyTargetRule>,
164    pub dedupe: OperationalPolicyDedupePolicy,
165    pub outcomes: OperationalPolicyOutcomePolicy,
166    pub permissions: OperationalPolicyAutomationPermissions,
167}
168
169#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
170#[serde(deny_unknown_fields)]
171pub struct OperationalPolicySourceRule {
172    pub source_id: NonEmptyString,
173    /// Open provider identifier (e.g.
174    /// `operational_policy_source_provider::SLACK`). Any value an adapter
175    /// publishes is accepted on the wire.
176    pub provider: NonEmptyString,
177    pub allowed_locators: Vec<NonEmptyString>,
178    pub allowed_actions: Vec<OperationalPolicyAction>,
179    pub source_thread: OperationalPolicySourceThreadPolicy,
180    #[serde(skip_serializing_if = "Option::is_none")]
181    pub minimum_confidence: Option<f64>,
182    /// Open per-provider adapter policy bag, keyed by adapter identifier
183    /// (typically the source provider id). Adapters validate their own slice;
184    /// the contract layer carries the JSON through untyped so new providers do
185    /// not require a contract edit.
186    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
187    pub adapter_policy: BTreeMap<NonEmptyString, JsonValue>,
188}
189
190#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize, RunxSchema)]
191#[serde(deny_unknown_fields)]
192pub struct OperationalPolicySourceThreadPolicy {
193    pub required: bool,
194    pub publish_mode: OperationalPolicyPublishMode,
195    pub missing_behavior: OperationalPolicyMissingBehavior,
196}
197
198#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
199#[serde(deny_unknown_fields)]
200pub struct OperationalPolicyRunnerRule {
201    pub runner_id: NonEmptyString,
202    /// Open runner kind identifier (e.g.
203    /// `operational_policy_runner_kind::LOCAL`). Any value an adapter publishes
204    /// is accepted on the wire.
205    pub kind: NonEmptyString,
206    pub state: OperationalPolicyRunnerState,
207    pub allowed_actions: Vec<OperationalPolicyAction>,
208    pub target_repos: Vec<String>,
209    pub scafld_required: bool,
210}
211
212#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
213#[serde(deny_unknown_fields)]
214pub struct OperationalPolicyOwnerRoute {
215    pub route_id: NonEmptyString,
216    pub owners: Vec<NonEmptyString>,
217    pub target_repos: Vec<String>,
218    #[serde(default, skip_serializing_if = "Vec::is_empty")]
219    pub labels: Vec<NonEmptyString>,
220    #[serde(skip_serializing_if = "Option::is_none")]
221    pub project: Option<NonEmptyString>,
222}
223
224#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
225#[serde(deny_unknown_fields)]
226pub struct OperationalPolicyTargetRule {
227    pub repo: String,
228    pub runner_ids: Vec<NonEmptyString>,
229    pub allowed_actions: Vec<OperationalPolicyAction>,
230    pub default_owner_route: NonEmptyString,
231    pub scafld_required: bool,
232    #[serde(skip_serializing_if = "Option::is_none")]
233    pub base_branch: Option<NonEmptyString>,
234}
235
236#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
237#[serde(deny_unknown_fields)]
238pub struct OperationalPolicyDedupePolicy {
239    pub strategy: OperationalPolicyDedupeStrategy,
240    pub key_fields: Vec<NonEmptyString>,
241    pub on_duplicate: OperationalPolicyDuplicateBehavior,
242}
243
244#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, RunxSchema)]
245#[serde(deny_unknown_fields)]
246pub struct OperationalPolicyOutcomePolicy {
247    pub observe_provider: bool,
248    pub verification_required: bool,
249    pub close_source_issue: OperationalPolicyOutcomeCloseMode,
250    pub publish_final_source_thread_update: bool,
251}
252
253#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
254#[serde(deny_unknown_fields)]
255pub struct OperationalPolicyAutomationPermissions {
256    pub auto_merge: bool,
257    pub mutate_target_repo: bool,
258    pub require_human_merge_gate: bool,
259}
260
261impl RunxSchema for OperationalPolicy {
262    fn json_schema() -> Value {
263        object_schema(
264            vec![
265                Property::new("schema", OperationalPolicySchema::json_schema(), true),
266                Property::new(
267                    "schema_version",
268                    OperationalPolicySchema::json_schema(),
269                    true,
270                ),
271                Property::new("policy_id", id_schema(), true),
272                Property::new("created_at", IsoDateTime::json_schema(), false),
273                Property::new(
274                    "sources",
275                    non_empty_array(OperationalPolicySourceRule::json_schema()),
276                    true,
277                ),
278                Property::new(
279                    "runners",
280                    non_empty_array(OperationalPolicyRunnerRule::json_schema()),
281                    true,
282                ),
283                Property::new(
284                    "owner_routes",
285                    non_empty_array(OperationalPolicyOwnerRoute::json_schema()),
286                    true,
287                ),
288                Property::new(
289                    "targets",
290                    non_empty_array(OperationalPolicyTargetRule::json_schema()),
291                    true,
292                ),
293                Property::new("dedupe", OperationalPolicyDedupePolicy::json_schema(), true),
294                Property::new(
295                    "outcomes",
296                    OperationalPolicyOutcomePolicy::json_schema(),
297                    true,
298                ),
299                Property::new(
300                    "permissions",
301                    OperationalPolicyAutomationPermissions::json_schema(),
302                    true,
303                ),
304            ],
305            true,
306            Some(Identity::Runx {
307                logical: "runx.operational_policy.v1",
308                url: None,
309            }),
310        )
311    }
312}
313
314impl RunxSchema for OperationalPolicySourceRule {
315    fn json_schema() -> Value {
316        object_schema(
317            vec![
318                Property::new("source_id", id_schema(), true),
319                Property::new("provider", NonEmptyString::json_schema(), true),
320                Property::new(
321                    "allowed_locators",
322                    non_empty_array(NonEmptyString::json_schema()),
323                    true,
324                ),
325                Property::new(
326                    "allowed_actions",
327                    non_empty_array(OperationalPolicyAction::json_schema()),
328                    true,
329                ),
330                Property::new(
331                    "source_thread",
332                    OperationalPolicySourceThreadPolicy::json_schema(),
333                    true,
334                ),
335                Property::new("minimum_confidence", confidence_schema(), false),
336                Property::new("adapter_policy", adapter_policy_schema(), false),
337            ],
338            true,
339            None,
340        )
341    }
342}
343
344impl RunxSchema for OperationalPolicyRunnerRule {
345    fn json_schema() -> Value {
346        object_schema(
347            vec![
348                Property::new("runner_id", id_schema(), true),
349                Property::new("kind", NonEmptyString::json_schema(), true),
350                Property::new("state", OperationalPolicyRunnerState::json_schema(), true),
351                Property::new(
352                    "allowed_actions",
353                    non_empty_array(OperationalPolicyAction::json_schema()),
354                    true,
355                ),
356                Property::new("target_repos", non_empty_array(repo_slug_schema()), true),
357                Property::new("scafld_required", bool::json_schema(), true),
358            ],
359            true,
360            None,
361        )
362    }
363}
364
365impl RunxSchema for OperationalPolicyOwnerRoute {
366    fn json_schema() -> Value {
367        object_schema(
368            vec![
369                Property::new("route_id", id_schema(), true),
370                Property::new(
371                    "owners",
372                    non_empty_array(NonEmptyString::json_schema()),
373                    true,
374                ),
375                Property::new("target_repos", non_empty_array(repo_slug_schema()), true),
376                Property::new("labels", Vec::<NonEmptyString>::json_schema(), false),
377                Property::new("project", NonEmptyString::json_schema(), false),
378            ],
379            true,
380            None,
381        )
382    }
383}
384
385impl RunxSchema for OperationalPolicyTargetRule {
386    fn json_schema() -> Value {
387        object_schema(
388            vec![
389                Property::new("repo", repo_slug_schema(), true),
390                Property::new("runner_ids", non_empty_array(id_schema()), true),
391                Property::new(
392                    "allowed_actions",
393                    non_empty_array(OperationalPolicyAction::json_schema()),
394                    true,
395                ),
396                Property::new("default_owner_route", id_schema(), true),
397                Property::new("scafld_required", bool::json_schema(), true),
398                Property::new("base_branch", NonEmptyString::json_schema(), false),
399            ],
400            true,
401            None,
402        )
403    }
404}
405
406impl RunxSchema for OperationalPolicyDedupePolicy {
407    fn json_schema() -> Value {
408        object_schema(
409            vec![
410                Property::new(
411                    "strategy",
412                    OperationalPolicyDedupeStrategy::json_schema(),
413                    true,
414                ),
415                Property::new(
416                    "key_fields",
417                    non_empty_array(NonEmptyString::json_schema()),
418                    true,
419                ),
420                Property::new(
421                    "on_duplicate",
422                    OperationalPolicyDuplicateBehavior::json_schema(),
423                    true,
424                ),
425            ],
426            true,
427            None,
428        )
429    }
430}
431
432impl RunxSchema for OperationalPolicyAutomationPermissions {
433    fn json_schema() -> Value {
434        object_schema(
435            vec![
436                Property::new("auto_merge", const_bool(false), true),
437                Property::new("mutate_target_repo", bool::json_schema(), true),
438                Property::new("require_human_merge_gate", const_bool(true), true),
439            ],
440            true,
441            None,
442        )
443    }
444}
445
446fn repo_slug_schema() -> Value {
447    json!({
448        "minLength": 3,
449        "pattern": "^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$",
450        "type": "string"
451    })
452}
453
454fn id_schema() -> Value {
455    json!({
456        "minLength": 1,
457        "pattern": "^[A-Za-z0-9_.:-]+$",
458        "type": "string"
459    })
460}
461
462fn confidence_schema() -> Value {
463    json!({ "minimum": 0, "maximum": 1, "type": "number" })
464}
465
466fn non_empty_array(item_schema: Value) -> Value {
467    json!({ "items": item_schema, "minItems": 1, "type": "array" })
468}
469
470fn const_bool(value: bool) -> Value {
471    json!({ "const": value, "type": "boolean" })
472}
473
474fn adapter_policy_schema() -> Value {
475    json!({
476        "additionalProperties": JsonValue::json_schema(),
477        "propertyNames": NonEmptyString::json_schema(),
478        "type": "object",
479    })
480}
481
482#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
483pub struct OperationalPolicyValidationFinding {
484    pub code: String,
485    pub path: String,
486    pub message: String,
487}
488
489#[derive(Clone, Debug, Eq, PartialEq)]
490pub struct OperationalPolicyAdmissionRequest {
491    pub source_id: Option<String>,
492    pub target_repo: Option<String>,
493    pub action: OperationalPolicyAction,
494    pub runner_id: Option<String>,
495    pub source_thread_locator: Option<String>,
496}
497
498#[derive(Clone, Debug, PartialEq, Serialize)]
499pub struct OperationalPolicyAdmission {
500    pub status: OperationalPolicyAdmissionStatus,
501    pub findings: Vec<OperationalPolicyValidationFinding>,
502    pub policy_id: String,
503    #[serde(skip_serializing_if = "Option::is_none")]
504    pub source_id: Option<String>,
505    #[serde(skip_serializing_if = "Option::is_none")]
506    pub target_repo: Option<String>,
507    #[serde(skip_serializing_if = "Option::is_none")]
508    pub runner_id: Option<String>,
509    #[serde(skip_serializing_if = "Option::is_none")]
510    pub owner_route_id: Option<String>,
511    #[serde(skip_serializing_if = "Option::is_none")]
512    pub owners: Option<Vec<String>>,
513    pub dedupe_strategy: OperationalPolicyDedupeStrategy,
514    pub outcome_close_mode: OperationalPolicyOutcomeCloseMode,
515    pub source_thread_required: bool,
516    pub mutate_target_repo: bool,
517    pub require_human_merge_gate: bool,
518}
519
520#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize)]
521#[serde(rename_all = "lowercase")]
522pub enum OperationalPolicyAdmissionStatus {
523    Allow,
524    Deny,
525}
526
527#[derive(Clone, Debug, Eq, PartialEq)]
528pub enum OperationalPolicyError {
529    Contract(OperationalPolicyValidationFinding),
530    Semantic(OperationalPolicyValidationFinding),
531}
532
533impl OperationalPolicyError {
534    pub fn finding(&self) -> &OperationalPolicyValidationFinding {
535        match self {
536            Self::Contract(finding) | Self::Semantic(finding) => finding,
537        }
538    }
539}
540
541impl fmt::Display for OperationalPolicyError {
542    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
543        let finding = self.finding();
544        write!(
545            formatter,
546            "{} failed validation ({}): {}",
547            finding.path, finding.code, finding.message
548        )
549    }
550}
551
552impl std::error::Error for OperationalPolicyError {}
553
554#[derive(Clone, Debug, PartialEq, Serialize)]
555pub struct OperationalPolicyReadback {
556    pub policy_id: String,
557    pub schema_version: OperationalPolicySchema,
558    pub valid: bool,
559    pub findings: Vec<OperationalPolicyValidationFinding>,
560    pub sources: Vec<OperationalPolicySourceReadback>,
561    pub runners: Vec<OperationalPolicyRunnerReadback>,
562    pub targets: Vec<OperationalPolicyTargetReadback>,
563    pub outcomes: OperationalPolicyOutcomePolicy,
564    pub permissions: OperationalPolicyAutomationPermissions,
565}
566
567#[derive(Clone, Debug, PartialEq, Serialize)]
568pub struct OperationalPolicySourceReadback {
569    pub source_id: String,
570    pub provider: NonEmptyString,
571    pub locator_count: usize,
572    pub allowed_actions: Vec<OperationalPolicyAction>,
573    pub source_thread_required: bool,
574    pub publish_mode: OperationalPolicyPublishMode,
575}
576
577#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
578pub struct OperationalPolicyRunnerReadback {
579    pub runner_id: String,
580    pub kind: NonEmptyString,
581    pub state: OperationalPolicyRunnerState,
582    pub target_repos: Vec<String>,
583    pub allowed_actions: Vec<OperationalPolicyAction>,
584    pub scafld_required: bool,
585}
586
587#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
588pub struct OperationalPolicyTargetReadback {
589    pub repo: String,
590    pub runner_ids: Vec<String>,
591    pub default_owner_route: String,
592    pub owner_count: usize,
593    pub allowed_actions: Vec<OperationalPolicyAction>,
594    pub scafld_required: bool,
595    pub available_runner_count: usize,
596}
597
598fn action_name(action: OperationalPolicyAction) -> &'static str {
599    match action {
600        OperationalPolicyAction::ReplyOnly => "reply-only",
601        OperationalPolicyAction::IssueIntake => "issue-intake",
602        OperationalPolicyAction::WorkPlan => "work-plan",
603        OperationalPolicyAction::IssueToPr => "issue-to-pr",
604        OperationalPolicyAction::ManualReview => "manual-review",
605        OperationalPolicyAction::PrReview => "pr-review",
606        OperationalPolicyAction::PrFixUp => "pr-fix-up",
607        OperationalPolicyAction::MergeAssist => "merge-assist",
608    }
609}