wyrd-spec 0.3.0

Wyrd foundation types: Card envelope, SpecKinds, and pure data contracts.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
//! Verifier building blocks: named inputs, optional preparation steps,
//! requirements, and one decision rule.
//!
//! A [`VerifierSpec`] declares the evidence it needs as named input slots,
//! optionally prepares that evidence with `extract`, `agent`, or `code`
//! steps, and judges each declared requirement with one check. Step and
//! requirement IDs share one namespace across the whole Verifier and are
//! used unchanged in references, results, route overrides, and fitted drift
//! state. These values are declarative: resolution, execution, and storage
//! belong to their runtime owners.
//!
//! Subjects attach a Verifier through a [`VerificationBinding`] in their own
//! `verified_by` list; the binding is part of the containing Card version and
//! is never itself a Card.

use std::collections::BTreeMap;

use schemars::JsonSchema;
use serde::de::Error as _;
use serde::{Deserialize, Deserializer, Serialize, Serializer};
use serde_json::Value;
use skald_spec::MediaFormat;

use crate::card::agent::AgentSpec;
use crate::card::drift::DriftSpec;
use crate::card::operator::OperatorSpec;
use crate::card::prompt::ParameterName;
use crate::card::trigger::TriggerSpec;
use crate::ids::{BindingId, FeatureName, SlotName};
use crate::reference::{CardRef, InlineableRef, Ref};
use crate::verification::ContextCapture;
use crate::verification::VerificationError;
use crate::verification::{ComparisonOperator, JsonPath, TaskId};

pub mod extract;
pub mod reference;
mod schema_path;
mod validate;

pub use reference::{
    OrderBy, PathSegment, Reference, ReferenceError, ReferenceRoot, SourceField, SourceReference,
    SourceText, VerdictField,
};
pub use validate::ResolvedAgent;

/// Most retries one step may declare after its first attempt.
pub const MAX_STEP_RETRIES: u32 = 5;

/// A subject-less Verifier: inputs, optional steps, requirements, decision.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
#[cfg_attr(feature = "server", derive(utoipa::ToSchema))]
#[serde(deny_unknown_fields)]
pub struct VerifierSpec {
    /// Optional human-readable purpose.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub description: Option<String>,
    /// Named evidence slots, addressed as `inputs.<slot>`.
    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
    #[schemars(with = "BTreeMap<SlotName, InputSlotSchema>")]
    #[cfg_attr(feature = "server", schema(value_type = BTreeMap<String, InputSlotSchema>))]
    pub inputs: BTreeMap<SlotName, InputSlot>,
    /// Preparation steps; independent steps may run concurrently.
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub steps: Vec<Step>,
    /// Whole-Verifier requirements, judged after every step finishes.
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub requirements: Vec<Requirement>,
    /// How requirement outcomes combine into the verdict.
    #[serde(default, skip_serializing_if = "Decide::is_all")]
    pub decide: Decide,
    /// How permitted `actual` values are stored; absent means full capture.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    #[cfg_attr(feature = "server", schema(value_type = Option<Object>))]
    pub context_capture: Option<ContextCapture>,
    /// Order of a group Verifier's source lists: `recorded_at`, or an input
    /// key that orders every slot reading the same source binding. Absent
    /// means `recorded_at`.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub order_by: Option<OrderBy>,
}

impl VerifierSpec {
    /// Returns every Agent call with its authored check or action path, in
    /// step order and then Verifier-requirement order.
    ///
    /// Paths name the call itself, such as `spec.steps[0].action` or
    /// `spec.requirements[1].check`, so callers can append the field they
    /// report on.
    #[must_use]
    pub fn agent_calls(&self) -> Vec<(String, &AgentCall)> {
        /// Push each Agent check under `prefix`.
        fn checks<'a>(
            requirements: &'a [Requirement],
            prefix: &str,
            calls: &mut Vec<(String, &'a AgentCall)>,
        ) {
            for (i, requirement) in requirements.iter().enumerate() {
                if let Check::Agent(call) = &requirement.check {
                    calls.push((format!("{prefix}[{i}].check"), call));
                }
            }
        }
        let mut calls = Vec::new();
        for (i, step) in self.steps.iter().enumerate() {
            if let Action::Agent(call) = &step.action {
                calls.push((format!("spec.steps[{i}].action"), call));
            }
            checks(
                &step.requirements,
                &format!("spec.steps[{i}].requirements"),
                &mut calls,
            );
        }
        checks(&self.requirements, "spec.requirements", &mut calls);
        calls
    }

    /// Returns every requirement, step requirements first in step order,
    /// then the whole-Verifier requirements.
    pub fn all_requirements(&self) -> impl Iterator<Item = &Requirement> {
        self.steps
            .iter()
            .flat_map(|step| &step.requirements)
            .chain(&self.requirements)
    }

    /// Returns every drift check with its requirement ID; the ID keys the
    /// check's fitted baseline.
    pub fn drift_checks(&self) -> impl Iterator<Item = (&TaskId, &DriftCheck)> {
        self.all_requirements()
            .filter_map(|requirement| match &requirement.check {
                Check::Drift(drift) => Some((&requirement.id, drift)),
                _ => None,
            })
    }
}

/// The JSON Schema of an authored [`InputSlot`]: one typed slot object or a
/// source reference string, exactly the forms [`InputSlot`] decodes.
///
/// schemars projects a per-variant `#[serde(untagged)]` as one more tagged
/// object, so the derived schema of [`InputSlot`] omits its source variant
/// and this uninhabited type unions it back in for [`VerifierSpec::inputs`].
enum InputSlotSchema {}

impl JsonSchema for InputSlotSchema {
    /// Names the authored slot schema in generated definitions.
    fn schema_name() -> String {
        "InputSlot".to_owned()
    }

    /// One of the typed slot objects or the source reference string.
    fn json_schema(generator: &mut schemars::r#gen::SchemaGenerator) -> schemars::schema::Schema {
        use schemars::schema::{Metadata, SchemaObject, SubschemaValidation};

        SchemaObject {
            metadata: Some(Box::new(Metadata {
                description: Some(
                    "One named evidence slot. Slots are required unless `required: false`."
                        .to_owned(),
                ),
                ..Metadata::default()
            })),
            subschemas: Some(Box::new(SubschemaValidation {
                one_of: Some(vec![
                    generator.subschema_for::<InputSlot>(),
                    generator.subschema_for::<SourceText>(),
                ]),
                ..SubschemaValidation::default()
            })),
            ..SchemaObject::default()
        }
        .into()
    }
}

#[cfg(feature = "server")]
impl utoipa::PartialSchema for InputSlotSchema {
    /// The derived typed slot objects with the source variant, which utoipa
    /// projects as an unsatisfiable tagged string, replaced by the source
    /// reference string.
    fn schema() -> utoipa::openapi::RefOr<utoipa::openapi::schema::Schema> {
        use utoipa::openapi::RefOr;
        use utoipa::openapi::schema::Schema;

        let mut slot = <InputSlot as utoipa::PartialSchema>::schema();
        if let RefOr::T(Schema::OneOf(one_of)) = &mut slot {
            one_of
                .items
                .retain(|item| !matches!(item, RefOr::T(Schema::AllOf(_))));
            one_of
                .items
                .push(<SourceText as utoipa::PartialSchema>::schema());
        }
        slot
    }
}

#[cfg(feature = "server")]
impl utoipa::ToSchema for InputSlotSchema {
    /// Names the authored slot schema as the served contract's `InputSlot`.
    fn name() -> std::borrow::Cow<'static, str> {
        "InputSlot".into()
    }

    /// Registers the schemas the typed slot objects reference.
    fn schemas(
        schemas: &mut Vec<(
            String,
            utoipa::openapi::RefOr<utoipa::openapi::schema::Schema>,
        )>,
    ) {
        <InputSlot as utoipa::ToSchema>::schemas(schemas);
    }
}

/// One named evidence slot. Slots are required unless `required: false`;
/// samples and source slots are always required.
#[derive(Debug, Clone, PartialEq, Serialize, JsonSchema)]
#[cfg_attr(feature = "server", derive(utoipa::ToSchema))]
#[serde(tag = "type", rename_all = "snake_case", deny_unknown_fields)]
#[schemars(rename = "TypedInputSlot")]
pub enum InputSlot {
    /// A JSON value, optionally constrained by JSON Schema.
    Json {
        /// JSON Schema the value must satisfy.
        #[serde(default, skip_serializing_if = "Option::is_none")]
        schema: Option<Value>,
        /// Whether a caller must supply the slot.
        #[serde(default = "required_default", skip_serializing_if = "is_true")]
        required: bool,
    },
    /// A pinned file in one of the accepted formats.
    Media {
        /// Accepted formats.
        #[cfg_attr(feature = "server", schema(value_type = Vec<String>))]
        formats: Vec<MediaFormat>,
        /// Lower size bound than the deployment limit.
        #[serde(default, skip_serializing_if = "Option::is_none")]
        max_bytes: Option<u64>,
        /// Whether a caller must supply the slot.
        #[serde(default = "required_default", skip_serializing_if = "is_true")]
        required: bool,
    },
    /// A trace resolved through tenant-scoped trace reads.
    Trace {
        /// Whether a caller must supply the slot.
        #[serde(default = "required_default", skip_serializing_if = "is_true")]
        required: bool,
    },
    /// Feature measurements or metric samples recorded for a scheduled window.
    ///
    /// Always required: a schedule binding supplies every samples slot of its
    /// Verifier from the subject's recorded measurements, so the slot has no
    /// `required` field and no drift check can be omitted by configuration.
    Samples {
        /// Measurement names, including metric names.
        features: Vec<FeatureName>,
    },
    /// A group Verifier's input authored as a source reference string,
    /// `verifiers.<name>.inputs.<slot>` or
    /// `verifiers.<name>.requirements.<requirement>.outcome`, resolved at
    /// group execution to one ordered value per source execution.
    #[serde(untagged)]
    #[schemars(skip)]
    Source(SourceText),
}

/// The tagged forms of [`InputSlot`], decoded without its untagged source
/// fallback so a malformed typed slot reports its own error, such as an
/// unknown field, instead of "did not match any variant". The remote derive
/// constructs each [`InputSlot`] variant, so a field added there and missing
/// here fails to compile.
#[derive(Deserialize)]
#[serde(
    remote = "InputSlot",
    tag = "type",
    rename_all = "snake_case",
    deny_unknown_fields
)]
enum TypedInputSlot {
    /// [`InputSlot::Json`].
    Json {
        /// JSON Schema the value must satisfy.
        #[serde(default)]
        schema: Option<Value>,
        /// Whether a caller must supply the slot.
        #[serde(default = "required_default")]
        required: bool,
    },
    /// [`InputSlot::Media`].
    Media {
        /// Accepted formats.
        formats: Vec<MediaFormat>,
        /// Lower size bound than the deployment limit.
        #[serde(default)]
        max_bytes: Option<u64>,
        /// Whether a caller must supply the slot.
        #[serde(default = "required_default")]
        required: bool,
    },
    /// [`InputSlot::Trace`].
    Trace {
        /// Whether a caller must supply the slot.
        #[serde(default = "required_default")]
        required: bool,
    },
    /// [`InputSlot::Samples`].
    Samples {
        /// Measurement names, including metric names.
        features: Vec<FeatureName>,
    },
}

/// Decode a string as a source declaration and a map as a typed slot, the
/// same two forms the derived `untagged` source variant accepts.
impl<'de> Deserialize<'de> for InputSlot {
    /// Dispatch on the authored value's shape without buffering it.
    ///
    /// # Errors
    /// Returns the typed slot's own decode error for a malformed map, and a
    /// type error for anything that is neither a string nor a map.
    fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
        /// Visits one authored slot value.
        struct Slot;
        impl<'de> serde::de::Visitor<'de> for Slot {
            /// The decoded slot: a verbatim source declaration or a typed slot.
            type Value = InputSlot;

            /// Names the two accepted forms in a type error.
            ///
            /// # Errors
            /// Returns the formatter's error when writing the description fails.
            fn expecting(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
                formatter.write_str("a typed input slot or a source reference string")
            }

            /// A string is a source declaration, kept verbatim; its reference
            /// grammar is validated later, not while decoding.
            ///
            /// # Errors
            /// None in practice: the source text wrapper accepts any string.
            fn visit_str<E: serde::de::Error>(self, text: &str) -> Result<InputSlot, E> {
                SourceText::deserialize(serde::de::value::StrDeserializer::new(text))
                    .map(InputSlot::Source)
            }

            /// A map is a typed slot, decoded by the tagged typed-slot decoder.
            ///
            /// # Errors
            /// Returns that decoder's error for a missing or unknown `type` tag,
            /// an unknown or missing field, or a malformed field value.
            fn visit_map<A: serde::de::MapAccess<'de>>(
                self,
                map: A,
            ) -> Result<InputSlot, A::Error> {
                TypedInputSlot::deserialize(serde::de::value::MapAccessDeserializer::new(map))
            }
        }
        deserializer.deserialize_any(Slot)
    }
}

impl InputSlot {
    /// Returns whether a value must be supplied for this slot.
    #[must_use]
    pub fn required(&self) -> bool {
        match self {
            Self::Json { required, .. }
            | Self::Media { required, .. }
            | Self::Trace { required } => *required,
            Self::Samples { .. } | Self::Source(_) => true,
        }
    }
}

/// Serde default making slots required unless declared otherwise.
fn required_default() -> bool {
    true
}

/// Serde skip predicate omitting the default `required: true`.
#[expect(
    clippy::trivially_copy_pass_by_ref,
    reason = "serde skip_serializing_if passes the field by reference"
)]
fn is_true(value: &bool) -> bool {
    *value
}

/// One preparation step: an action plus the requirements judged on it.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
#[cfg_attr(feature = "server", derive(utoipa::ToSchema))]
#[serde(deny_unknown_fields)]
pub struct Step {
    /// Verifier-unique block ID.
    pub id: TaskId,
    /// The data-producing action.
    pub action: Action,
    /// Ordering-only dependencies beyond those implied by references.
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub depends_on: Vec<TaskId>,
    /// Gate: the step runs only when the condition holds; otherwise its
    /// requirements are not applicable.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub when: Option<Condition>,
    /// Requirements judged after the action, before dependents start.
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub requirements: Vec<Requirement>,
    /// Per-attempt timeout in seconds.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub timeout_seconds: Option<u32>,
    /// Retries after the first failed attempt, at most [`MAX_STEP_RETRIES`].
    #[serde(default, skip_serializing_if = "is_zero")]
    pub retries: u32,
}

/// Serde skip predicate omitting zero retries.
#[expect(
    clippy::trivially_copy_pass_by_ref,
    reason = "serde skip_serializing_if passes the field by reference"
)]
fn is_zero(value: &u32) -> bool {
    *value == 0
}

/// A data-producing step action. Actions read and compute only.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
#[cfg_attr(feature = "server", derive(utoipa::ToSchema))]
#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)]
pub enum Action {
    /// Extract text and structure from one media slot.
    Extract {
        /// The media slot, as `inputs.<slot>`.
        from: Reference,
    },
    /// Invoke an Agent; its output follows the Prompt's response schema.
    Agent(AgentCall),
    /// Run sandboxed code returning JSON data.
    Code(CodeCall),
}

/// One Agent invocation in a step or check position.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
#[cfg_attr(feature = "server", derive(utoipa::ToSchema))]
#[serde(deny_unknown_fields)]
pub struct AgentCall {
    /// The Agent, inline, file-authored, or registered.
    pub agent: InlineableRef<AgentSpec>,
    /// Injected values keyed by the Prompt's text and media placeholders.
    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
    pub inputs: BTreeMap<ParameterName, Reference>,
    /// Files under `/inputs/...` for the Agent's tools; step position only.
    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
    pub workspace: BTreeMap<String, Reference>,
}

/// One sandboxed code invocation in a step or check position.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
#[cfg_attr(feature = "server", derive(utoipa::ToSchema))]
#[serde(deny_unknown_fields)]
pub struct CodeCall {
    /// Execution runtime.
    #[serde(default, skip_serializing_if = "CodeRuntime::is_default")]
    pub runtime: CodeRuntime,
    /// Immutable source.
    pub source: CodeSource,
    /// Function called with the bound inputs as keyword arguments.
    pub entrypoint: String,
    /// Values passed to the entrypoint.
    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
    pub inputs: BTreeMap<ParameterName, Reference>,
}

/// A code execution runtime.
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[cfg_attr(feature = "server", derive(utoipa::ToSchema))]
#[serde(rename_all = "snake_case")]
pub enum CodeRuntime {
    /// Sandboxed Python; the only supported runtime.
    #[default]
    Python,
}

impl CodeRuntime {
    /// Serde skip predicate omitting the default runtime.
    #[must_use]
    pub fn is_default(&self) -> bool {
        *self == Self::default()
    }
}

/// Immutable code source.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
#[cfg_attr(feature = "server", derive(utoipa::ToSchema))]
#[serde(rename_all = "snake_case", deny_unknown_fields)]
pub enum CodeSource {
    /// Source text embedded in the Verifier version.
    Inline(String),
    /// A file inside an exact Artifact Card version.
    Artifact {
        /// The Artifact Card.
        card: Ref,
        /// File path inside the Artifact, when it holds more than one file.
        #[serde(default, skip_serializing_if = "Option::is_none")]
        path: Option<String>,
    },
}

/// One declared requirement, producing exactly one verdict.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
#[cfg_attr(feature = "server", derive(utoipa::ToSchema))]
#[serde(deny_unknown_fields)]
pub struct Requirement {
    /// Verifier-unique block ID.
    pub id: TaskId,
    /// The claim the check judges, readable as `requirement.statement`.
    pub statement: String,
    /// The verdict-producing check.
    pub check: Check,
}

/// A verdict-producing requirement check.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
#[cfg_attr(feature = "server", derive(utoipa::ToSchema))]
#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)]
pub enum Check {
    /// Typed comparison over one value (`path`) or every wildcard match
    /// (`each`); exactly one is set.
    Assert {
        /// One value.
        #[serde(default, skip_serializing_if = "Option::is_none")]
        path: Option<Reference>,
        /// Every value selected through `[*]`.
        #[serde(default, skip_serializing_if = "Option::is_none")]
        each: Option<Reference>,
        /// Comparison operator.
        operator: ComparisonOperator,
        /// Right-hand side of the comparison.
        #[serde(default, skip_serializing_if = "Value::is_null")]
        expected: Value,
    },
    /// Trace assertion over a trace slot.
    Trace(TraceCheck),
    /// A tool-less Agent returning the Verdict schema.
    Agent(AgentCall),
    /// Sandboxed code returning the Verdict schema.
    Code(CodeCall),
    /// Statistical drift over a samples slot.
    Drift(DriftCheck),
}

/// The evidence mode a check needs.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum CheckEvidence {
    /// Evidence supplied with one execution request.
    Run,
    /// Samples recorded for a scheduled window.
    Window,
}

impl Check {
    /// Returns the evidence mode this check kind needs.
    #[must_use]
    pub fn evidence(&self) -> CheckEvidence {
        match self {
            Self::Drift(_) => CheckEvidence::Window,
            Self::Assert { .. } | Self::Trace(_) | Self::Agent(_) | Self::Code(_) => {
                CheckEvidence::Run
            }
        }
    }
}

/// Trace assertion over a declared trace slot.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
#[cfg_attr(feature = "server", derive(utoipa::ToSchema))]
#[serde(deny_unknown_fields)]
pub struct TraceCheck {
    /// The trace slot, as `inputs.<slot>`.
    pub on: Reference,
    /// JSONPath into the assembled trace document.
    pub span_selector: JsonPath,
    /// Comparison operator.
    pub operator: ComparisonOperator,
    /// Right-hand side of the comparison.
    #[serde(default, skip_serializing_if = "Value::is_null")]
    pub expected: Value,
}

/// Drift check: an existing [`DriftSpec`] over one samples slot.
///
/// The wire form flattens the drift fields beside `on`; the drift fields
/// keep their own unknown-field refusal.
#[derive(Debug, Clone, PartialEq, JsonSchema)]
pub struct DriftCheck {
    /// The samples slot, as `inputs.<slot>`.
    pub on: Reference,
    /// Method, signal, condition, and profile.
    #[schemars(flatten)]
    pub spec: DriftSpec,
}

#[cfg(feature = "server")]
impl utoipa::PartialSchema for DriftCheck {
    /// Describes the flattened wire form: every [`DriftSpec`] field plus the
    /// required `on` reference.
    fn schema() -> utoipa::openapi::RefOr<utoipa::openapi::schema::Schema> {
        use utoipa::openapi::schema::{AllOfBuilder, ObjectBuilder};
        AllOfBuilder::new()
            .item(utoipa::openapi::Ref::from_schema_name("DriftSpec"))
            .item(
                ObjectBuilder::new()
                    .property("on", <Reference as utoipa::PartialSchema>::schema())
                    .required("on"),
            )
            .into()
    }
}

#[cfg(feature = "server")]
impl utoipa::ToSchema for DriftCheck {
    /// Registers the referenced [`DriftSpec`] component and its closure.
    fn schemas(
        schemas: &mut Vec<(
            String,
            utoipa::openapi::RefOr<utoipa::openapi::schema::Schema>,
        )>,
    ) {
        schemas.push((
            <DriftSpec as utoipa::ToSchema>::name().into(),
            <DriftSpec as utoipa::PartialSchema>::schema(),
        ));
        <DriftSpec as utoipa::ToSchema>::schemas(schemas);
    }
}

impl Serialize for DriftCheck {
    /// Writes the drift spec's fields with `on` beside them as its reference
    /// text, the flattened wire form authors write.
    ///
    /// # Errors
    /// Returns the serializer's error, or a custom error when the drift spec
    /// does not serialize to JSON.
    fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
        let mut value = serde_json::to_value(&self.spec).map_err(serde::ser::Error::custom)?;
        if let Value::Object(fields) = &mut value {
            fields.insert("on".to_owned(), Value::String(self.on.to_string()));
        }
        value.serialize(serializer)
    }
}

impl<'de> Deserialize<'de> for DriftCheck {
    /// Splits the required `on` reference out of the flattened object and
    /// decodes the remaining fields as a [`DriftSpec`], which keeps its own
    /// unknown-field refusal.
    ///
    /// # Errors
    /// Returns a missing-field error when `on` is absent, and a custom error
    /// when `on` is not a valid reference or the remaining fields are not a
    /// valid drift spec.
    fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
        let mut value = Value::deserialize(deserializer)?;
        let on = value
            .as_object_mut()
            .and_then(|fields| fields.remove("on"))
            .ok_or_else(|| D::Error::missing_field("on"))?;
        Ok(Self {
            on: Reference::deserialize(on).map_err(D::Error::custom)?,
            spec: DriftSpec::deserialize(value).map_err(D::Error::custom)?,
        })
    }
}

/// Step gate comparing one referenced value.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
#[cfg_attr(feature = "server", derive(utoipa::ToSchema))]
#[serde(deny_unknown_fields)]
pub struct Condition {
    /// The compared value.
    pub path: Reference,
    /// Comparison operator.
    pub operator: ComparisonOperator,
    /// Right-hand side of the comparison.
    #[serde(default, skip_serializing_if = "Value::is_null")]
    pub expected: Value,
}

/// How requirement outcomes combine. Not-applicable requirements are
/// excluded; no applicable requirement is inconclusive.
#[derive(Debug, Clone, Copy, Default, PartialEq, Serialize, Deserialize, JsonSchema)]
#[cfg_attr(feature = "server", derive(utoipa::ToSchema))]
#[serde(rename_all = "snake_case", deny_unknown_fields)]
pub enum Decide {
    /// Any failure fails; otherwise any inconclusive is inconclusive.
    #[default]
    All,
    /// Passed share of applicable requirements, counting inconclusive ones,
    /// must reach `threshold` in `(0, 1]`.
    PassRate {
        /// Minimum passed share.
        threshold: f64,
    },
}

impl Decide {
    /// Serde skip predicate omitting the default rule.
    #[must_use]
    pub fn is_all(&self) -> bool {
        matches!(self, Self::All)
    }
}

/// Reserved subject-occurrence key of a Service-level or standalone-Agent
/// verification binding.
///
/// A binding's natural key names its subject occurrence: a Service component
/// binding uses the component alias, and a binding on the owner itself uses
/// this value. Composition validation refuses it as a component alias, so the
/// owner and component domains of the key never collide and the key is never
/// null.
pub const OWNER_OCCURRENCE_KEY: &str = "$owner";

/// Server-derived verification state projected onto `card.status`.
///
/// A Service or standalone Agent owning `verified_by` bindings exposes the
/// stable identity of each projected binding here, so a caller can address a
/// binding without a separate listing resource. The field is read-only: it is
/// derived from the tenant's binding projection on every Card read and is
/// never persisted as Card status.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
#[cfg_attr(feature = "server", derive(utoipa::ToSchema))]
pub struct VerificationStatus {
    /// Stable identities of this owner Card version's projected bindings,
    /// ordered by identity so reordering authored bindings changes nothing.
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub binding_ids: Vec<BindingId>,
    /// Fitted-baseline readiness of a PSI or SPC Drift Verifier Card version.
    ///
    /// Absent on every other Card, including a Custom Drift Verifier, which
    /// scores its authored scalar baseline and is ready without fitting.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub baseline: Option<DriftBaselineStatus>,
}

impl VerificationStatus {
    /// Build the status a Card read serves, or `None` when it has nothing to report.
    #[must_use]
    pub fn derived(
        binding_ids: Vec<BindingId>,
        baseline: Option<DriftBaselineStatus>,
    ) -> Option<Self> {
        (!binding_ids.is_empty() || baseline.is_some()).then_some(Self {
            binding_ids,
            baseline,
        })
    }
}

/// Lifecycle of one PSI or SPC Verifier's server-fitted baseline.
///
/// Registration creates it `pending`; the fitter claims it `building` and
/// settles it `ready` with the persisted fitted profile or `failed` with a
/// structured error. A failed fit is retried through the same record while
/// attempts remain, so `failed` is visible without being necessarily final.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
#[cfg_attr(feature = "server", derive(utoipa::ToSchema))]
#[serde(rename_all = "snake_case")]
pub enum DriftBaselineState {
    /// Registered and waiting for the fitter.
    Pending,
    /// Claimed by a fitter that is reading and fitting the baseline Data.
    Building,
    /// Fitted and persisted; runs may start.
    Ready,
    /// The last fit attempt failed; see the structured error.
    Failed,
}

impl DriftBaselineState {
    /// Stored and wire value of this state.
    #[must_use]
    pub const fn as_str(self) -> &'static str {
        match self {
            Self::Pending => "pending",
            Self::Building => "building",
            Self::Ready => "ready",
            Self::Failed => "failed",
        }
    }

    /// Decode a stored state value, or `None` outside the closed set.
    #[must_use]
    pub fn parse(value: &str) -> Option<Self> {
        match value {
            "pending" => Some(Self::Pending),
            "building" => Some(Self::Building),
            "ready" => Some(Self::Ready),
            "failed" => Some(Self::Failed),
            _ => None,
        }
    }
}

/// Server-derived fitted-baseline status of a PSI or SPC Drift Verifier.
///
/// Served on `card.status.verification.baseline`; it never mutates the
/// authored spec.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
#[cfg_attr(feature = "server", derive(utoipa::ToSchema))]
pub struct DriftBaselineStatus {
    /// Current fit lifecycle state.
    pub state: DriftBaselineState,
    /// Exact baseline Data Card the profile is fitted from, UID-pinned.
    pub data: CardRef,
    /// Structured error of the last failed fit attempt, when one failed.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub error: Option<VerificationError>,
}

/// One inline declaration attaching a Verifier to its containing subject.
///
/// Authored on a Service, a Service component occurrence, or a standalone
/// Agent. Registration resolves and UID-pins the Verifier, the effective
/// Trigger, and every effective Operator before the owning Card persists.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, schemars::JsonSchema)]
#[cfg_attr(feature = "server", derive(utoipa::ToSchema))]
#[serde(deny_unknown_fields)]
pub struct VerificationBinding {
    /// The exact Verifier Card this subject is verified by.
    pub verifier: Ref,
    /// When the bound Verifier runs, inline or as a referenced Trigger Card.
    ///
    /// Absent means explicit-only: the binding attaches the Verifier to its
    /// subject for direct invocation and nothing activates it.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub runs_on: Option<InlineableRef<TriggerSpec>>,
    /// Operators dispatched independently after a `failed` verdict.
    ///
    /// Empty means a failed result stays durable with no reaction. A
    /// `group_end` binding refuses this list; it reacts through
    /// [`Self::on_verdict_change`] instead.
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub on_failure: Vec<InlineableRef<OperatorSpec>>,
    /// Operators dispatched independently when a settled group judgment's
    /// verdict differs from the preceding settled judgment of the same
    /// binding and group.
    ///
    /// Valid only on a `group_end` binding. The first judgment of a group is
    /// the baseline and dispatches nothing; an unchanged verdict dispatches
    /// nothing.
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub on_verdict_change: Vec<InlineableRef<OperatorSpec>>,
}

impl VerificationBinding {
    /// Each authored Operator list beside its field name, in declaration
    /// order, so every validator and reference walker covers both reactions.
    #[must_use]
    pub fn operator_lists(&self) -> [(&'static str, &[InlineableRef<OperatorSpec>]); 2] {
        [
            ("on_failure", &self.on_failure),
            ("on_verdict_change", &self.on_verdict_change),
        ]
    }

    /// Each authored Operator list beside its field name, mutably, for
    /// reference pinning.
    pub fn operator_lists_mut(
        &mut self,
    ) -> [(&'static str, &mut Vec<InlineableRef<OperatorSpec>>); 2] {
        [
            ("on_failure", &mut self.on_failure),
            ("on_verdict_change", &mut self.on_verdict_change),
        ]
    }
}

/// Schema projection parity of authored Verifier input slots.
#[cfg(test)]
mod tests {
    use serde_json::{Value, json};

    use super::{InputSlot, VerifierSpec};

    /// The generated schema offers exactly the four typed slot objects and
    /// the source string, the forms decoding accepts; a `{type: "source"}`
    /// object is neither generated nor decoded.
    ///
    /// # Panics
    /// Panics when the schema or decoding admits another form.
    #[test]
    fn input_slot_schema_matches_decoding() {
        let schema = serde_json::to_value(schemars::schema_for!(VerifierSpec))
            .expect("invariant: a schema serializes");
        let definitions = &schema["definitions"];
        assert_eq!(
            definitions["InputSlot"]["oneOf"],
            json!([
                {"$ref": "#/definitions/TypedInputSlot"},
                {"$ref": "#/definitions/SourceText"}
            ])
        );
        let tags: Vec<&Value> = definitions["TypedInputSlot"]["oneOf"]
            .as_array()
            .expect("invariant: typed slots are a oneOf")
            .iter()
            .map(|variant| &variant["properties"]["type"]["enum"][0])
            .collect();
        assert_eq!(
            tags,
            [
                &json!("json"),
                &json!("media"),
                &json!("trace"),
                &json!("samples")
            ]
        );
        assert_eq!(definitions["SourceText"]["type"], json!("string"));

        let decode = |value| serde_json::from_value::<InputSlot>(value);
        assert!(matches!(
            decode(json!("verifiers.turn.inputs.message")),
            Ok(InputSlot::Source(_))
        ));
        assert!(decode(json!({"type": "trace"})).is_ok());
        assert!(decode(json!({"type": "source"})).is_err());
    }

    /// The served projection offers the same forms: the four typed slot
    /// objects and the source string, never a tagged source object.
    ///
    /// # Panics
    /// Panics when the served schema admits another form.
    #[cfg(feature = "server")]
    #[test]
    fn served_input_slot_schema_matches_decoding() {
        let schema =
            serde_json::to_value(<super::InputSlotSchema as utoipa::PartialSchema>::schema())
                .expect("invariant: a schema serializes");
        let forms: Vec<Value> = schema["oneOf"]
            .as_array()
            .expect("invariant: slots are a oneOf")
            .iter()
            .map(|form| form["properties"]["type"]["enum"][0].clone())
            .collect();
        assert_eq!(
            forms,
            [
                json!("json"),
                json!("media"),
                json!("trace"),
                json!("samples"),
                Value::Null
            ]
        );
        assert_eq!(schema["oneOf"][4]["type"], json!("string"));
    }
}