Skip to main content

canwu_sim/runtime/
policy.rs

1use super::{CanwuError, ErrorCode, 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}
85
86/// Command-relevant policy deliberately omits run purpose, observation, and
87/// trace so authoritative handlers cannot branch on presentation-only inputs.
88#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
89#[serde(tag = "provenance", rename_all = "snake_case")]
90pub enum CommandPolicyContext {
91    Declared {
92        format_version: u32,
93        controller: ControllerPolicy,
94        seat: SeatPolicy,
95        interaction: InteractionPolicy,
96        diagnostic_commands_enabled: bool,
97    },
98    CompatibilityV1,
99    ManifestOnlyV1,
100    LegacyUnspecified,
101}
102
103impl RunConfiguration {
104    #[must_use]
105    pub fn play_as_character(
106        seat_id: impl Into<String>,
107        controller_id: impl Into<String>,
108        actor: PersonId,
109        permission_profile_id: impl Into<String>,
110    ) -> Self {
111        Self {
112            format_version: RUN_CONFIGURATION_FORMAT_VERSION,
113            purpose: RunPurpose::Play,
114            controller: ControllerPolicy::HumanRoleBound,
115            seat: SeatPolicy::CharacterBound,
116            observation: ObservationPolicy::ActorBound,
117            interaction: InteractionPolicy::EraInternalCommands,
118            trace: TracePolicy::Causal,
119            seat_binding: Some(SeatBinding {
120                seat_id: seat_id.into(),
121                controller_id: controller_id.into(),
122                actor: Some(actor),
123                institution: None,
124                permission_profile_id: permission_profile_id.into(),
125            }),
126            declared_interventions: Vec::new(),
127            diagnostic_commands_enabled: false,
128            require_idempotency_keys: true,
129        }
130    }
131
132    #[must_use]
133    pub fn read_only_observer() -> Self {
134        Self {
135            format_version: RUN_CONFIGURATION_FORMAT_VERSION,
136            purpose: RunPurpose::HistoricalSimulation,
137            controller: ControllerPolicy::NoHuman,
138            seat: SeatPolicy::None,
139            observation: ObservationPolicy::PublicObserver,
140            interaction: InteractionPolicy::ReadOnly,
141            trace: TracePolicy::Causal,
142            seat_binding: None,
143            declared_interventions: Vec::new(),
144            diagnostic_commands_enabled: false,
145            require_idempotency_keys: true,
146        }
147    }
148
149    #[must_use]
150    pub fn replay_as_character(
151        seat_id: impl Into<String>,
152        recorded_controller_id: impl Into<String>,
153        actor: PersonId,
154        permission_profile_id: impl Into<String>,
155    ) -> Self {
156        Self {
157            format_version: RUN_CONFIGURATION_FORMAT_VERSION,
158            purpose: RunPurpose::Replay,
159            controller: ControllerPolicy::ReplayController,
160            seat: SeatPolicy::CharacterBound,
161            observation: ObservationPolicy::ActorBound,
162            interaction: InteractionPolicy::ReadOnly,
163            trace: TracePolicy::Causal,
164            seat_binding: Some(SeatBinding {
165                seat_id: seat_id.into(),
166                controller_id: recorded_controller_id.into(),
167                actor: Some(actor),
168                institution: None,
169                permission_profile_id: permission_profile_id.into(),
170            }),
171            declared_interventions: Vec::new(),
172            diagnostic_commands_enabled: false,
173            require_idempotency_keys: true,
174        }
175    }
176
177    pub(crate) fn canonicalize(&mut self) {
178        self.declared_interventions.sort();
179        self.declared_interventions.dedup();
180    }
181
182    pub(crate) fn validate(&self) -> Result<(), CanwuError> {
183        if self.format_version != RUN_CONFIGURATION_FORMAT_VERSION {
184            return invalid_configuration(format!(
185                "run configuration format {} is unsupported",
186                self.format_version
187            ));
188        }
189        if self
190            .declared_interventions
191            .windows(2)
192            .any(|pair| pair[0] >= pair[1])
193            || self
194                .declared_interventions
195                .iter()
196                .any(|value| !canonical_text(value))
197        {
198            return invalid_configuration(
199                "declared interventions must be unique, canonical, and sorted",
200            );
201        }
202
203        match self.seat {
204            SeatPolicy::CharacterBound => {
205                let Some(binding) = &self.seat_binding else {
206                    return invalid_configuration(
207                        "a character-bound seat requires an exact seat binding",
208                    );
209                };
210                if binding.actor.is_none() || binding.institution.is_some() {
211                    return invalid_configuration(
212                        "a character-bound seat requires one actor and no institution",
213                    );
214                }
215            }
216            SeatPolicy::InstitutionBound => {
217                if self
218                    .seat_binding
219                    .as_ref()
220                    .and_then(|binding| binding.institution.as_ref())
221                    .is_none()
222                {
223                    return invalid_configuration(
224                        "an institution-bound seat requires an institution binding",
225                    );
226                }
227            }
228            SeatPolicy::ObserverSeat | SeatPolicy::AdvisorSeat => {
229                if self.controller == ControllerPolicy::HumanRoleBound
230                    && self.seat_binding.is_none()
231                {
232                    return invalid_configuration(
233                        "a human observer or advisor seat requires an exact seat binding",
234                    );
235                }
236            }
237            SeatPolicy::None if self.seat_binding.is_some() => {
238                return invalid_configuration("seat policy none cannot retain a seat binding");
239            }
240            SeatPolicy::None => {}
241        }
242
243        if let Some(binding) = &self.seat_binding
244            && (!canonical_text(&binding.seat_id)
245                || !canonical_text(&binding.controller_id)
246                || !canonical_text(&binding.permission_profile_id))
247        {
248            return invalid_configuration(
249                "seat bindings require canonical seat, controller, and permission-profile IDs",
250            );
251        }
252        if self.controller == ControllerPolicy::HumanRoleBound && self.seat_binding.is_none() {
253            return invalid_configuration("human-role-bound runs require a seat binding");
254        }
255        if self.controller == ControllerPolicy::NoHuman && self.seat_binding.is_some() {
256            return invalid_configuration("no-human runs cannot retain a controller seat binding");
257        }
258        if self.observation == ObservationPolicy::ActorBound && self.seat_binding.is_none() {
259            return invalid_configuration("actor-bound observation requires a seat binding");
260        }
261        if self.observation == ObservationPolicy::ActorBound
262            && self.interaction == InteractionPolicy::EraInternalCommands
263            && (self.controller != ControllerPolicy::HumanRoleBound
264                || !matches!(
265                    self.seat,
266                    SeatPolicy::CharacterBound | SeatPolicy::InstitutionBound
267                ))
268        {
269            return invalid_configuration(
270                "actor-bound command runs require a human character or institution seat",
271            );
272        }
273        if self.observation == ObservationPolicy::PublicObserver
274            && self.interaction != InteractionPolicy::ReadOnly
275        {
276            return invalid_configuration("public-observer runs must be read-only");
277        }
278        if self.observation == ObservationPolicy::ResearchFull
279            && !matches!(
280                self.interaction,
281                InteractionPolicy::ReadOnly | InteractionPolicy::VersionedExperiment
282            )
283        {
284            return invalid_configuration(
285                "research-full runs must be read-only or a versioned experiment",
286            );
287        }
288        if (self.purpose == RunPurpose::Replay
289            || self.controller == ControllerPolicy::ReplayController)
290            && (self.purpose != RunPurpose::Replay
291                || self.controller != ControllerPolicy::ReplayController
292                || self.interaction != InteractionPolicy::ReadOnly)
293        {
294            return invalid_configuration(
295                "replay purpose, replay controller, and read-only interaction must be selected together",
296            );
297        }
298        if self.interaction == InteractionPolicy::VersionedExperiment
299            && (self.declared_interventions.is_empty()
300                || self.observation != ObservationPolicy::ResearchFull
301                || !matches!(
302                    self.purpose,
303                    RunPurpose::HistoricalSimulation
304                        | RunPurpose::Validation
305                        | RunPurpose::DeveloperDiagnostic
306                ))
307        {
308            return invalid_configuration(
309                "versioned experiments require research observation, a research-capable purpose, and declared interventions",
310            );
311        }
312        if (self.purpose == RunPurpose::DeveloperDiagnostic
313            || self.observation == ObservationPolicy::DeveloperDiagnostic
314            || self.diagnostic_commands_enabled)
315            && (self.purpose != RunPurpose::DeveloperDiagnostic
316                || self.observation != ObservationPolicy::DeveloperDiagnostic)
317        {
318            return invalid_configuration(
319                "developer diagnostics require both diagnostic purpose and observation policy",
320            );
321        }
322        Ok(())
323    }
324
325    pub(crate) fn semantic_hash(&self) -> Result<String, CanwuError> {
326        canonical_hash("canwu.run-configuration.v1", self)
327    }
328}
329
330#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
331#[serde(
332    tag = "provenance",
333    content = "configuration",
334    rename_all = "snake_case"
335)]
336pub enum RunConfigurationSnapshot {
337    Declared(RunConfiguration),
338    CompatibilityV1,
339    ManifestOnlyV1,
340    LegacyUnspecified,
341}
342
343impl RunConfigurationSnapshot {
344    pub(crate) fn validate(&self) -> Result<(), CanwuError> {
345        match self {
346            Self::Declared(configuration) => configuration.validate(),
347            Self::CompatibilityV1 | Self::ManifestOnlyV1 | Self::LegacyUnspecified => Ok(()),
348        }
349    }
350
351    pub(crate) fn semantic_hash(&self) -> Result<Option<String>, CanwuError> {
352        match self {
353            Self::Declared(configuration) => configuration.semantic_hash().map(Some),
354            Self::CompatibilityV1 => compatibility_configuration_hash().map(Some),
355            Self::ManifestOnlyV1 | Self::LegacyUnspecified => Ok(None),
356        }
357    }
358
359    #[must_use]
360    pub const fn declared(&self) -> Option<&RunConfiguration> {
361        match self {
362            Self::Declared(configuration) => Some(configuration),
363            Self::CompatibilityV1 | Self::ManifestOnlyV1 | Self::LegacyUnspecified => None,
364        }
365    }
366
367    pub(crate) const fn command_policy(&self) -> CommandPolicyContext {
368        match self {
369            Self::Declared(configuration) => CommandPolicyContext::Declared {
370                format_version: configuration.format_version,
371                controller: configuration.controller,
372                seat: configuration.seat,
373                interaction: configuration.interaction,
374                diagnostic_commands_enabled: configuration.diagnostic_commands_enabled,
375            },
376            Self::CompatibilityV1 => CommandPolicyContext::CompatibilityV1,
377            Self::ManifestOnlyV1 => CommandPolicyContext::ManifestOnlyV1,
378            Self::LegacyUnspecified => CommandPolicyContext::LegacyUnspecified,
379        }
380    }
381}
382
383pub(crate) fn compatibility_configuration_hash() -> Result<String, CanwuError> {
384    #[derive(Serialize)]
385    struct CompatibilityConfiguration<'a> {
386        scheduler: &'a str,
387        settlement: &'a str,
388        observation: &'a str,
389        trace: &'a str,
390    }
391
392    canonical_hash(
393        "canwu.default-run-configuration.v1",
394        &CompatibilityConfiguration {
395            scheduler: "canonical-single-host-v1",
396            settlement: "explicit-fourteen-phase-v1",
397            observation: "actor-scoped-v1",
398            trace: "authoritative-evidence-v1",
399        },
400    )
401}
402
403pub(crate) fn authoritative_configuration_hash() -> Result<String, CanwuError> {
404    canonical_hash(
405        "canwu.authoritative-run-configuration.v1",
406        &"run-purpose and admission/presentation policy are excluded from simulated-state identity; admitted inputs remain authoritative",
407    )
408}
409
410fn canonical_text(value: &str) -> bool {
411    !value.is_empty() && value == value.trim()
412}
413
414fn invalid_configuration<T>(message: impl Into<String>) -> Result<T, CanwuError> {
415    Err(CanwuError::new(ErrorCode::InvalidRunConfiguration, message))
416}