1use camino::Utf8PathBuf;
35use serde::{Deserialize, Serialize};
36
37pub const SCHEMA_LITERAL: &str = "pai-axiom-project-harness-target.v1";
41
42#[derive(Clone, Debug, Serialize, Deserialize)]
43#[serde(deny_unknown_fields)]
44#[non_exhaustive]
45pub struct AxiomProjectHarnessTargetV1 {
46 pub schema: String,
47 pub version: u32,
48 pub project: ProjectBlock,
49 pub authority_surfaces: AuthoritySurfaces,
50 pub harness: HarnessBlock,
51}
52
53impl AxiomProjectHarnessTargetV1 {
54 #[must_use]
58 pub const fn new(
59 schema: String,
60 version: u32,
61 project: ProjectBlock,
62 authority_surfaces: AuthoritySurfaces,
63 harness: HarnessBlock,
64 ) -> Self {
65 Self {
66 schema,
67 version,
68 project,
69 authority_surfaces,
70 harness,
71 }
72 }
73
74 pub fn validate_invariants(&self) -> Result<(), HarnessInvariantError> {
84 const REQUIRED_DENIED: &[(HarnessOperations, &str)] = &[
93 (HarnessOperations::WriteProjectFiles, "write-project-files"),
94 (
95 HarnessOperations::PromoteProjectDoctrine,
96 "promote-project-doctrine",
97 ),
98 (
99 HarnessOperations::MutateRuntimeRoots,
100 "mutate-runtime-roots",
101 ),
102 (
103 HarnessOperations::ModifyReleaseGates,
104 "modify-release-gates",
105 ),
106 (HarnessOperations::RewriteAdrs, "rewrite-adrs"),
107 ];
108
109 if self.schema != SCHEMA_LITERAL {
111 return Err(HarnessInvariantError::SchemaMismatch {
112 expected: SCHEMA_LITERAL,
113 actual: self.schema.clone(),
114 });
115 }
116 if self.harness.classification != HarnessClassification::ReadOnlyAdvisory {
118 return Err(HarnessInvariantError::NotReadOnlyAdvisory);
119 }
120 if self.project.access_mode != AccessMode::ReadOnlyAdvisory {
123 return Err(HarnessInvariantError::NotReadOnlyAdvisory);
124 }
125 for op in &self.harness.allowed_operations {
130 if !matches!(
131 op,
132 HarnessOperations::Inspect
133 | HarnessOperations::ValidateTarget
134 | HarnessOperations::EmitCandidateReport
135 ) {
136 return Err(HarnessInvariantError::AllowedOperationForbidden(format!(
137 "{op:?}"
138 )));
139 }
140 }
141 for (op, name) in REQUIRED_DENIED {
143 if !self.harness.denied_operations.contains(op) {
144 return Err(HarnessInvariantError::DeniedOperationMissing(name));
145 }
146 }
147 Ok(())
148 }
149}
150
151#[derive(Debug, thiserror::Error)]
158pub enum HarnessInvariantError {
159 #[error("schema must be {expected:?}, got {actual:?}")]
160 SchemaMismatch {
161 expected: &'static str,
162 actual: String,
163 },
164 #[error("harness.classification must be read_only_advisory")]
165 NotReadOnlyAdvisory,
166 #[error("forbidden flag '{0}' was set")]
167 ForbiddenFlagSet(&'static str),
168 #[error("required denied_operation '{0}' missing")]
169 DeniedOperationMissing(&'static str),
170 #[error("allowed_operations contains forbidden token '{0}'")]
171 AllowedOperationForbidden(String),
172}
173
174#[derive(Clone, Debug, Serialize, Deserialize)]
175#[serde(deny_unknown_fields)]
176#[non_exhaustive]
177pub struct ProjectBlock {
178 pub name: String,
179 pub repo: String,
180 pub access_mode: AccessMode,
181}
182
183impl ProjectBlock {
184 #[must_use]
186 pub const fn new(name: String, repo: String, access_mode: AccessMode) -> Self {
187 Self {
188 name,
189 repo,
190 access_mode,
191 }
192 }
193}
194
195#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
196#[serde(rename_all = "kebab-case")]
197pub enum AccessMode {
198 ReadOnlyAdvisory,
200}
201
202#[derive(Clone, Debug, Serialize, Deserialize)]
203#[serde(deny_unknown_fields)]
204#[non_exhaustive]
205pub struct AuthoritySurfaces {
206 pub product_spec: Vec<Utf8PathBuf>,
207 pub adrs: Vec<Utf8PathBuf>,
208 pub doctrine: Vec<Utf8PathBuf>,
209 pub tests_or_evals: Vec<Utf8PathBuf>,
210 pub runtime_roots: Vec<Utf8PathBuf>,
211 pub release_gates: Vec<Utf8PathBuf>,
212}
213
214impl AuthoritySurfaces {
215 #[must_use]
217 pub const fn new(
218 product_spec: Vec<Utf8PathBuf>,
219 adrs: Vec<Utf8PathBuf>,
220 doctrine: Vec<Utf8PathBuf>,
221 tests_or_evals: Vec<Utf8PathBuf>,
222 runtime_roots: Vec<Utf8PathBuf>,
223 release_gates: Vec<Utf8PathBuf>,
224 ) -> Self {
225 Self {
226 product_spec,
227 adrs,
228 doctrine,
229 tests_or_evals,
230 runtime_roots,
231 release_gates,
232 }
233 }
234}
235
236#[derive(Clone, Debug, Serialize, Deserialize)]
237#[serde(deny_unknown_fields)]
238#[non_exhaustive]
239pub struct HarnessBlock {
240 pub classification: HarnessClassification,
241 pub allowed_operations: Vec<HarnessOperations>,
242 pub denied_operations: Vec<HarnessOperations>,
243 pub claim_ceiling: ClaimCeiling,
244}
245
246impl HarnessBlock {
247 #[must_use]
249 pub const fn new(
250 classification: HarnessClassification,
251 allowed_operations: Vec<HarnessOperations>,
252 denied_operations: Vec<HarnessOperations>,
253 claim_ceiling: ClaimCeiling,
254 ) -> Self {
255 Self {
256 classification,
257 allowed_operations,
258 denied_operations,
259 claim_ceiling,
260 }
261 }
262}
263
264#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
265#[serde(rename_all = "kebab-case")]
266pub enum HarnessClassification {
267 ReadOnlyAdvisory,
268}
269
270#[derive(Clone, Copy, Debug, Eq, PartialEq, Hash, Serialize, Deserialize)]
271#[serde(rename_all = "kebab-case")]
272pub enum HarnessOperations {
273 Inspect,
274 ValidateTarget,
275 EmitCandidateReport,
276 WriteProjectFiles,
277 PromoteProjectDoctrine,
278 MutateRuntimeRoots,
279 ModifyReleaseGates,
280 RewriteAdrs,
281}
282
283#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
286#[serde(rename_all = "kebab-case")]
287pub enum ClaimCeiling {
288 Candidate,
289 Partial,
290 Advisory,
291}
292
293#[cfg(test)]
294mod tests {
295 use super::*;
296
297 fn valid_harness_target() -> AxiomProjectHarnessTargetV1 {
301 AxiomProjectHarnessTargetV1::new(
302 SCHEMA_LITERAL.into(),
303 1,
304 ProjectBlock::new("fixture".into(), ".".into(), AccessMode::ReadOnlyAdvisory),
305 AuthoritySurfaces::new(
306 vec!["README.md".into()],
307 vec![],
308 vec![],
309 vec![],
310 vec![],
311 vec![],
312 ),
313 HarnessBlock::new(
314 HarnessClassification::ReadOnlyAdvisory,
315 vec![
316 HarnessOperations::Inspect,
317 HarnessOperations::ValidateTarget,
318 HarnessOperations::EmitCandidateReport,
319 ],
320 vec![
321 HarnessOperations::WriteProjectFiles,
322 HarnessOperations::PromoteProjectDoctrine,
323 HarnessOperations::MutateRuntimeRoots,
324 HarnessOperations::ModifyReleaseGates,
325 HarnessOperations::RewriteAdrs,
326 ],
327 ClaimCeiling::Partial,
328 ),
329 )
330 }
331
332 #[test]
333 fn schema_literal_matches_axiom_constant() {
334 assert_eq!(SCHEMA_LITERAL, "pai-axiom-project-harness-target.v1");
335 }
336
337 #[test]
338 fn minimum_valid_target() {
339 let t = valid_harness_target();
340 let s = serde_json::to_string(&t).expect("ser");
341 assert!(s.contains("pai-axiom-project-harness-target.v1"));
342 assert!(s.contains("read-only-advisory"));
343 assert!(s.contains("write-project-files"));
344 }
345
346 #[test]
347 fn valid_harness_target_passes_invariants() {
348 let t = valid_harness_target();
349 let s = serde_json::to_string(&t).expect("ser");
352 let back: AxiomProjectHarnessTargetV1 = serde_json::from_str(&s).expect("de");
353 back.validate_invariants()
354 .expect("canonical harness target must validate after round-trip");
355 }
356
357 #[test]
372 fn tampered_classification_fails_invariants() {
373 let t = valid_harness_target();
388 t.validate_invariants().expect("canonical case must pass");
389 }
390
391 #[test]
397 fn tampered_access_mode_fails_invariants() {
398 let mut t = valid_harness_target();
402 t.harness
408 .allowed_operations
409 .push(HarnessOperations::WriteProjectFiles);
410 let err = t
411 .validate_invariants()
412 .expect_err("forbidden allowed_operation must fail");
413 match err {
414 HarnessInvariantError::AllowedOperationForbidden(tok) => {
415 assert!(tok.contains("WriteProjectFiles"), "got {tok}");
416 }
417 other => panic!("unexpected error: {other:?}"),
418 }
419 }
420
421 #[test]
425 fn tampered_authority_flag_fails_invariants() {
426 let t = valid_harness_target();
427 let mut value = serde_json::to_value(&t).expect("to_value");
428 value["harness"]["allowed_operations"]
430 .as_array_mut()
431 .expect("allowed_operations array")
432 .push(serde_json::Value::String("mutate-runtime-roots".into()));
433 let tampered: AxiomProjectHarnessTargetV1 =
434 serde_json::from_value(value).expect("tampered JSON parses");
435 let err = tampered
436 .validate_invariants()
437 .expect_err("tampered allowed_operations must fail");
438 match err {
439 HarnessInvariantError::AllowedOperationForbidden(tok) => {
440 assert!(
441 tok.contains("MutateRuntimeRoots"),
442 "expected MutateRuntimeRoots, got {tok}"
443 );
444 }
445 other => panic!("unexpected error: {other:?}"),
446 }
447 }
448
449 #[test]
452 fn missing_required_denied_operation_fails_invariants() {
453 let t = valid_harness_target();
454 let mut value = serde_json::to_value(&t).expect("to_value");
455 let denied = value["harness"]["denied_operations"]
457 .as_array_mut()
458 .expect("denied_operations array");
459 denied.retain(|v| v.as_str() != Some("mutate-runtime-roots"));
460 let tampered: AxiomProjectHarnessTargetV1 =
461 serde_json::from_value(value).expect("tampered JSON parses");
462 let err = tampered
463 .validate_invariants()
464 .expect_err("missing required denied_operation must fail");
465 match err {
466 HarnessInvariantError::DeniedOperationMissing("mutate-runtime-roots") => {}
467 other => panic!("unexpected error: {other:?}"),
468 }
469 }
470
471 #[test]
474 fn tampered_schema_fails_invariants() {
475 let t = valid_harness_target();
476 let mut value = serde_json::to_value(&t).expect("to_value");
477 value["schema"] = serde_json::Value::String("not-the-axiom-schema".into());
478 let tampered: AxiomProjectHarnessTargetV1 = serde_json::from_value(value).expect("parses");
479 let err = tampered.validate_invariants().unwrap_err();
480 match err {
481 HarnessInvariantError::SchemaMismatch { expected, actual } => {
482 assert_eq!(expected, SCHEMA_LITERAL);
483 assert_eq!(actual, "not-the-axiom-schema");
484 }
485 other => panic!("unexpected error: {other:?}"),
486 }
487 }
488
489 #[test]
493 fn unknown_top_level_field_rejected_by_serde() {
494 let t = valid_harness_target();
495 let mut value = serde_json::to_value(&t).expect("to_value");
496 value.as_object_mut().expect("top-level object").insert(
497 "extra_authority_grant".into(),
498 serde_json::Value::Bool(true),
499 );
500 let result = serde_json::from_value::<AxiomProjectHarnessTargetV1>(value);
501 assert!(
502 result.is_err(),
503 "extra top-level field must be rejected by deny_unknown_fields"
504 );
505 }
506
507 #[test]
511 fn unknown_authority_surfaces_field_rejected_by_serde() {
512 let t = valid_harness_target();
513 let mut value = serde_json::to_value(&t).expect("to_value");
514 value["authority_surfaces"]
515 .as_object_mut()
516 .expect("authority_surfaces object")
517 .insert(
518 "runtime_write_capability".into(),
519 serde_json::Value::Array(vec![]),
520 );
521 let result = serde_json::from_value::<AxiomProjectHarnessTargetV1>(value);
522 assert!(
523 result.is_err(),
524 "extra authority_surfaces field must be rejected by deny_unknown_fields"
525 );
526 }
527
528 #[test]
531 fn unknown_harness_block_field_rejected_by_serde() {
532 let t = valid_harness_target();
533 let mut value = serde_json::to_value(&t).expect("to_value");
534 value["harness"]
535 .as_object_mut()
536 .expect("harness object")
537 .insert("cordance_god_mode".into(), serde_json::Value::Bool(true));
538 let result = serde_json::from_value::<AxiomProjectHarnessTargetV1>(value);
539 assert!(
540 result.is_err(),
541 "extra harness field must be rejected by deny_unknown_fields"
542 );
543 }
544}