Skip to main content

canwu_sim/runtime/
policy.rs

1use super::{CanwuError, ErrorCode, EvaluationLimitsV1, canonical_hash};
2use canwu_core::{EntityRef, PersonId};
3use serde::{Deserialize, Serialize};
4
5pub const RUN_CONFIGURATION_FORMAT_VERSION: u32 = 2;
6
7#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
8#[serde(rename_all = "snake_case")]
9pub enum RunPurpose {
10    Play,
11    HistoricalSimulation,
12    Validation,
13    Replay,
14    DeveloperDiagnostic,
15}
16
17#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
18#[serde(rename_all = "snake_case")]
19pub enum ControllerPolicy {
20    HumanRoleBound,
21    NoHuman,
22    ReplayController,
23}
24
25#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
26#[serde(rename_all = "snake_case")]
27pub enum SeatPolicy {
28    CharacterBound,
29    InstitutionBound,
30    ObserverSeat,
31    AdvisorSeat,
32    None,
33}
34
35#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
36#[serde(rename_all = "snake_case")]
37pub enum ObservationPolicy {
38    ActorBound,
39    PublicObserver,
40    ResearchFull,
41    DeveloperDiagnostic,
42}
43
44#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
45#[serde(rename_all = "snake_case")]
46pub enum InteractionPolicy {
47    EraInternalCommands,
48    ReadOnly,
49    VersionedExperiment,
50}
51
52#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
53#[serde(rename_all = "snake_case")]
54pub enum TracePolicy {
55    Minimal,
56    Causal,
57    Formula,
58    FullResearch,
59}
60
61#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
62pub struct SeatBinding {
63    pub seat_id: String,
64    pub controller_id: String,
65    pub actor: Option<PersonId>,
66    pub institution: Option<EntityRef>,
67    pub permission_profile_id: String,
68}
69
70#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
71pub struct RunConfiguration {
72    pub format_version: u32,
73    pub purpose: RunPurpose,
74    pub controller: ControllerPolicy,
75    pub seat: SeatPolicy,
76    pub observation: ObservationPolicy,
77    pub interaction: InteractionPolicy,
78    pub trace: TracePolicy,
79    pub seat_binding: Option<SeatBinding>,
80    #[serde(default)]
81    pub declared_interventions: Vec<String>,
82    pub diagnostic_commands_enabled: bool,
83    pub require_idempotency_keys: bool,
84    /// Per-boundary bounds on rule-evaluation traces. Omitted from the wire,
85    /// and so from the configuration hash, at [`EvaluationLimitsV1::DEFAULT`].
86    #[serde(default, skip_serializing_if = "EvaluationLimitsV1::is_default")]
87    pub evaluation_limits: EvaluationLimitsV1,
88}
89
90/// Command-relevant policy deliberately omits run purpose, observation, and
91/// trace so authoritative handlers cannot branch on presentation-only inputs.
92#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
93#[serde(tag = "provenance", rename_all = "snake_case")]
94pub enum CommandPolicyContext {
95    Declared {
96        format_version: u32,
97        controller: ControllerPolicy,
98        seat: SeatPolicy,
99        interaction: InteractionPolicy,
100        diagnostic_commands_enabled: bool,
101    },
102    CompatibilityV1,
103    ManifestOnlyV1,
104    LegacyUnspecified,
105}
106
107impl RunConfiguration {
108    #[must_use]
109    pub fn play_as_character(
110        seat_id: impl Into<String>,
111        controller_id: impl Into<String>,
112        actor: PersonId,
113        permission_profile_id: impl Into<String>,
114    ) -> Self {
115        Self {
116            format_version: RUN_CONFIGURATION_FORMAT_VERSION,
117            purpose: RunPurpose::Play,
118            controller: ControllerPolicy::HumanRoleBound,
119            seat: SeatPolicy::CharacterBound,
120            observation: ObservationPolicy::ActorBound,
121            interaction: InteractionPolicy::EraInternalCommands,
122            trace: TracePolicy::Causal,
123            seat_binding: Some(SeatBinding {
124                seat_id: seat_id.into(),
125                controller_id: controller_id.into(),
126                actor: Some(actor),
127                institution: None,
128                permission_profile_id: permission_profile_id.into(),
129            }),
130            declared_interventions: Vec::new(),
131            diagnostic_commands_enabled: false,
132            require_idempotency_keys: true,
133            evaluation_limits: EvaluationLimitsV1::DEFAULT,
134        }
135    }
136
137    #[must_use]
138    pub fn read_only_observer() -> Self {
139        Self {
140            format_version: RUN_CONFIGURATION_FORMAT_VERSION,
141            purpose: RunPurpose::HistoricalSimulation,
142            controller: ControllerPolicy::NoHuman,
143            seat: SeatPolicy::None,
144            observation: ObservationPolicy::PublicObserver,
145            interaction: InteractionPolicy::ReadOnly,
146            trace: TracePolicy::Causal,
147            seat_binding: None,
148            declared_interventions: Vec::new(),
149            diagnostic_commands_enabled: false,
150            require_idempotency_keys: true,
151            evaluation_limits: EvaluationLimitsV1::DEFAULT,
152        }
153    }
154
155    #[must_use]
156    pub fn replay_as_character(
157        seat_id: impl Into<String>,
158        recorded_controller_id: impl Into<String>,
159        actor: PersonId,
160        permission_profile_id: impl Into<String>,
161    ) -> Self {
162        Self {
163            format_version: RUN_CONFIGURATION_FORMAT_VERSION,
164            purpose: RunPurpose::Replay,
165            controller: ControllerPolicy::ReplayController,
166            seat: SeatPolicy::CharacterBound,
167            observation: ObservationPolicy::ActorBound,
168            interaction: InteractionPolicy::ReadOnly,
169            trace: TracePolicy::Causal,
170            seat_binding: Some(SeatBinding {
171                seat_id: seat_id.into(),
172                controller_id: recorded_controller_id.into(),
173                actor: Some(actor),
174                institution: None,
175                permission_profile_id: permission_profile_id.into(),
176            }),
177            declared_interventions: Vec::new(),
178            diagnostic_commands_enabled: false,
179            require_idempotency_keys: true,
180            evaluation_limits: EvaluationLimitsV1::DEFAULT,
181        }
182    }
183
184    /// Replaces the per-boundary rule-evaluation trace bounds.
185    #[must_use]
186    pub const fn with_evaluation_limits(mut self, limits: EvaluationLimitsV1) -> Self {
187        self.evaluation_limits = limits;
188        self
189    }
190
191    pub(crate) fn canonicalize(&mut self) {
192        self.declared_interventions.sort();
193        self.declared_interventions.dedup();
194    }
195
196    pub(crate) fn validate(&self) -> Result<(), CanwuError> {
197        if self.format_version != RUN_CONFIGURATION_FORMAT_VERSION {
198            return invalid_configuration(format!(
199                "run configuration format {} is unsupported",
200                self.format_version
201            ));
202        }
203        if self
204            .declared_interventions
205            .windows(2)
206            .any(|pair| pair[0] >= pair[1])
207            || self
208                .declared_interventions
209                .iter()
210                .any(|value| !canonical_text(value))
211        {
212            return invalid_configuration(
213                "declared interventions must be unique, canonical, and sorted",
214            );
215        }
216        self.evaluation_limits.validate()?;
217
218        match self.seat {
219            SeatPolicy::CharacterBound => {
220                let Some(binding) = &self.seat_binding else {
221                    return invalid_configuration(
222                        "a character-bound seat requires an exact seat binding",
223                    );
224                };
225                if binding.actor.is_none() || binding.institution.is_some() {
226                    return invalid_configuration(
227                        "a character-bound seat requires one actor and no institution",
228                    );
229                }
230            }
231            SeatPolicy::InstitutionBound => {
232                if self
233                    .seat_binding
234                    .as_ref()
235                    .and_then(|binding| binding.institution.as_ref())
236                    .is_none()
237                {
238                    return invalid_configuration(
239                        "an institution-bound seat requires an institution binding",
240                    );
241                }
242            }
243            SeatPolicy::ObserverSeat | SeatPolicy::AdvisorSeat => {
244                if self.controller == ControllerPolicy::HumanRoleBound
245                    && self.seat_binding.is_none()
246                {
247                    return invalid_configuration(
248                        "a human observer or advisor seat requires an exact seat binding",
249                    );
250                }
251            }
252            SeatPolicy::None if self.seat_binding.is_some() => {
253                return invalid_configuration("seat policy none cannot retain a seat binding");
254            }
255            SeatPolicy::None => {}
256        }
257
258        if let Some(binding) = &self.seat_binding
259            && (!canonical_text(&binding.seat_id)
260                || !canonical_text(&binding.controller_id)
261                || !canonical_text(&binding.permission_profile_id))
262        {
263            return invalid_configuration(
264                "seat bindings require canonical seat, controller, and permission-profile IDs",
265            );
266        }
267        if self.controller == ControllerPolicy::HumanRoleBound && self.seat_binding.is_none() {
268            return invalid_configuration("human-role-bound runs require a seat binding");
269        }
270        if self.controller == ControllerPolicy::NoHuman && self.seat_binding.is_some() {
271            return invalid_configuration("no-human runs cannot retain a controller seat binding");
272        }
273        if self.observation == ObservationPolicy::ActorBound && self.seat_binding.is_none() {
274            return invalid_configuration("actor-bound observation requires a seat binding");
275        }
276        if self.observation == ObservationPolicy::ActorBound
277            && self.interaction == InteractionPolicy::EraInternalCommands
278            && (self.controller != ControllerPolicy::HumanRoleBound
279                || !matches!(
280                    self.seat,
281                    SeatPolicy::CharacterBound | SeatPolicy::InstitutionBound
282                ))
283        {
284            return invalid_configuration(
285                "actor-bound command runs require a human character or institution seat",
286            );
287        }
288        if self.observation == ObservationPolicy::PublicObserver
289            && self.interaction != InteractionPolicy::ReadOnly
290        {
291            return invalid_configuration("public-observer runs must be read-only");
292        }
293        if self.observation == ObservationPolicy::ResearchFull
294            && !matches!(
295                self.interaction,
296                InteractionPolicy::ReadOnly | InteractionPolicy::VersionedExperiment
297            )
298        {
299            return invalid_configuration(
300                "research-full runs must be read-only or a versioned experiment",
301            );
302        }
303        if (self.purpose == RunPurpose::Replay
304            || self.controller == ControllerPolicy::ReplayController)
305            && (self.purpose != RunPurpose::Replay
306                || self.controller != ControllerPolicy::ReplayController
307                || self.interaction != InteractionPolicy::ReadOnly)
308        {
309            return invalid_configuration(
310                "replay purpose, replay controller, and read-only interaction must be selected together",
311            );
312        }
313        if self.interaction == InteractionPolicy::VersionedExperiment
314            && (self.declared_interventions.is_empty()
315                || self.observation != ObservationPolicy::ResearchFull
316                || !matches!(
317                    self.purpose,
318                    RunPurpose::HistoricalSimulation
319                        | RunPurpose::Validation
320                        | RunPurpose::DeveloperDiagnostic
321                ))
322        {
323            return invalid_configuration(
324                "versioned experiments require research observation, a research-capable purpose, and declared interventions",
325            );
326        }
327        if (self.purpose == RunPurpose::DeveloperDiagnostic
328            || self.observation == ObservationPolicy::DeveloperDiagnostic
329            || self.diagnostic_commands_enabled)
330            && (self.purpose != RunPurpose::DeveloperDiagnostic
331                || self.observation != ObservationPolicy::DeveloperDiagnostic)
332        {
333            return invalid_configuration(
334                "developer diagnostics require both diagnostic purpose and observation policy",
335            );
336        }
337        Ok(())
338    }
339
340    pub(crate) fn semantic_hash(&self) -> Result<String, CanwuError> {
341        canonical_hash("canwu.run-configuration.v1", self)
342    }
343}
344
345// One value per run: boxing the declared variant would change the public
346// shape for no measurable gain.
347#[allow(clippy::large_enum_variant)]
348#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
349#[serde(
350    tag = "provenance",
351    content = "configuration",
352    rename_all = "snake_case"
353)]
354pub enum RunConfigurationSnapshot {
355    Declared(RunConfiguration),
356    CompatibilityV1,
357    ManifestOnlyV1,
358    LegacyUnspecified,
359}
360
361impl RunConfigurationSnapshot {
362    pub(crate) fn validate(&self) -> Result<(), CanwuError> {
363        match self {
364            Self::Declared(configuration) => configuration.validate(),
365            Self::CompatibilityV1 | Self::ManifestOnlyV1 | Self::LegacyUnspecified => Ok(()),
366        }
367    }
368
369    pub(crate) fn semantic_hash(&self) -> Result<Option<String>, CanwuError> {
370        match self {
371            Self::Declared(configuration) => configuration.semantic_hash().map(Some),
372            Self::CompatibilityV1 => compatibility_configuration_hash().map(Some),
373            Self::ManifestOnlyV1 | Self::LegacyUnspecified => Ok(None),
374        }
375    }
376
377    #[must_use]
378    pub const fn declared(&self) -> Option<&RunConfiguration> {
379        match self {
380            Self::Declared(configuration) => Some(configuration),
381            Self::CompatibilityV1 | Self::ManifestOnlyV1 | Self::LegacyUnspecified => None,
382        }
383    }
384
385    /// The run's rule-evaluation trace bounds; undeclared provenance uses
386    /// [`EvaluationLimitsV1::DEFAULT`].
387    #[must_use]
388    pub const fn evaluation_limits(&self) -> EvaluationLimitsV1 {
389        match self {
390            Self::Declared(configuration) => configuration.evaluation_limits,
391            Self::CompatibilityV1 | Self::ManifestOnlyV1 | Self::LegacyUnspecified => {
392                EvaluationLimitsV1::DEFAULT
393            }
394        }
395    }
396
397    pub(crate) const fn command_policy(&self) -> CommandPolicyContext {
398        match self {
399            Self::Declared(configuration) => CommandPolicyContext::Declared {
400                format_version: configuration.format_version,
401                controller: configuration.controller,
402                seat: configuration.seat,
403                interaction: configuration.interaction,
404                diagnostic_commands_enabled: configuration.diagnostic_commands_enabled,
405            },
406            Self::CompatibilityV1 => CommandPolicyContext::CompatibilityV1,
407            Self::ManifestOnlyV1 => CommandPolicyContext::ManifestOnlyV1,
408            Self::LegacyUnspecified => CommandPolicyContext::LegacyUnspecified,
409        }
410    }
411}
412
413pub(crate) fn compatibility_configuration_hash() -> Result<String, CanwuError> {
414    #[derive(Serialize)]
415    struct CompatibilityConfiguration<'a> {
416        scheduler: &'a str,
417        settlement: &'a str,
418        observation: &'a str,
419        trace: &'a str,
420    }
421
422    canonical_hash(
423        "canwu.default-run-configuration.v1",
424        &CompatibilityConfiguration {
425            scheduler: "canonical-single-host-v1",
426            settlement: "explicit-fourteen-phase-v1",
427            observation: "actor-scoped-v1",
428            trace: "authoritative-evidence-v1",
429        },
430    )
431}
432
433pub(crate) fn authoritative_configuration_hash() -> Result<String, CanwuError> {
434    canonical_hash(
435        "canwu.authoritative-run-configuration.v1",
436        &"run-purpose and admission/presentation policy are excluded from simulated-state identity; admitted inputs remain authoritative",
437    )
438}
439
440fn canonical_text(value: &str) -> bool {
441    !value.is_empty() && value == value.trim()
442}
443
444fn invalid_configuration<T>(message: impl Into<String>) -> Result<T, CanwuError> {
445    Err(CanwuError::new(ErrorCode::InvalidRunConfiguration, message))
446}