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 #[serde(default, skip_serializing_if = "EvaluationLimitsV1::is_default")]
87 pub evaluation_limits: EvaluationLimitsV1,
88}
89
90#[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 #[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#[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 #[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}