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#[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}