1use schemars::JsonSchema;
2use serde::{Deserialize, Serialize};
3use stack_ids::{
4 ClaimId, ClaimStateId, ClaimVersionId, ContentDigest, ContradictionWitnessId,
5 RetractionRecordId, SemanticsProfileId, SupportSetId,
6};
7
8use crate::{DegradationKindV1, EvidenceAdmissibilityV1, ExactnessLevelV1, SemanticViewV1};
9
10pub const BILATTICE_TRUTH_V1_SCHEMA: &str = "bilattice_truth_v1";
11pub const SUPPORT_SET_V1_SCHEMA: &str = "support_set_v1";
12pub const CONTRADICTION_WITNESS_V1_SCHEMA: &str = "contradiction_witness_v1";
13pub const RETRACTION_RECORD_V1_SCHEMA: &str = "retraction_record_v1";
14pub const CLAIM_STATE_V13_SCHEMA: &str = "claim_state_v13";
15
16#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
17#[serde(rename_all = "snake_case")]
18pub enum BilatticeTruthV1 {
19 Unknown,
20 TrueOnly,
21 FalseOnly,
22 Both,
23}
24
25#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
26#[serde(rename_all = "snake_case")]
27pub enum SupportPolarityV1 {
28 Supports,
29 Refutes,
30}
31
32#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
33#[serde(rename_all = "snake_case")]
34pub enum SupportProvenanceKindV1 {
35 EvidenceRef,
36 ClaimVersion,
37 RelationVersion,
38 Episode,
39 Receipt,
40 External,
41}
42
43#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
44pub struct SupportTokenV1 {
45 pub token_id: String,
46 pub kind: SupportProvenanceKindV1,
47 pub reference: String,
48 pub polarity: SupportPolarityV1,
49}
50
51#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
52#[serde(tag = "op", rename_all = "snake_case")]
53pub enum SupportExprV1 {
54 Token { token_id: String },
55 AnyOf { children: Vec<SupportExprV1> },
56 AllOf { children: Vec<SupportExprV1> },
57}
58
59#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
60pub struct SupportSetV1 {
61 pub schema_version: String,
62 pub support_set_id: SupportSetId,
63 pub claim_id: ClaimId,
64 pub semantics_profile_id: SemanticsProfileId,
65 pub support_tokens: Vec<SupportTokenV1>,
66 pub support_expr: SupportExprV1,
67 pub content_digest: ContentDigest,
68}
69
70impl SupportSetV1 {
71 pub fn validate(&self) -> Result<(), String> {
73 ensure_schema(&self.schema_version, SUPPORT_SET_V1_SCHEMA)?;
74 ensure_non_empty_id(self.support_set_id.as_str(), "support_set_id")?;
75 ensure_non_empty_id(self.claim_id.as_str(), "claim_id")?;
76 ensure_non_empty_id(self.semantics_profile_id.as_str(), "semantics_profile_id")?;
77 ensure_non_empty_vec(&self.support_tokens, "support_tokens")?;
78 validate_support_expr(&self.support_expr, &self.support_tokens)?;
79 Ok(())
80 }
81}
82
83#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
84pub struct QualityVectorV1 {
85 pub exactness: ExactnessLevelV1,
86 #[serde(default, skip_serializing_if = "Vec::is_empty")]
87 pub degradation: Vec<DegradationKindV1>,
88 #[serde(default, skip_serializing_if = "Option::is_none")]
89 pub freshness: Option<String>,
90 pub replay_limited: bool,
91 pub execution_contaminated: bool,
92}
93
94#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
95pub struct ContradictionWitnessV1 {
96 pub schema_version: String,
97 pub contradiction_witness_id: ContradictionWitnessId,
98 pub claim_id: ClaimId,
99 #[serde(default, skip_serializing_if = "Vec::is_empty")]
100 pub conflicting_token_ids: Vec<String>,
101 #[serde(default, skip_serializing_if = "Option::is_none")]
102 pub summary: Option<String>,
103}
104
105impl ContradictionWitnessV1 {
106 pub fn validate(&self) -> Result<(), String> {
108 ensure_schema(&self.schema_version, CONTRADICTION_WITNESS_V1_SCHEMA)?;
109 ensure_non_empty_id(
110 self.contradiction_witness_id.as_str(),
111 "contradiction_witness_id",
112 )?;
113 ensure_non_empty_id(self.claim_id.as_str(), "claim_id")?;
114 ensure_non_empty_vec(&self.conflicting_token_ids, "conflicting_token_ids")
115 }
116}
117
118#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
119pub struct RetractionRecordV1 {
120 pub schema_version: String,
121 pub retraction_record_id: RetractionRecordId,
122 pub claim_id: ClaimId,
123 pub retracted_claim_version_id: ClaimVersionId,
124 #[serde(default, skip_serializing_if = "Option::is_none")]
125 pub superseded_by_claim_version_id: Option<ClaimVersionId>,
126 pub effective_recorded_at: String,
127 pub reason: String,
128 pub cascade_required: bool,
129 #[serde(default, skip_serializing_if = "Option::is_none")]
130 pub delta_summary: Option<String>,
131}
132
133impl RetractionRecordV1 {
134 pub fn validate(&self) -> Result<(), String> {
136 ensure_schema(&self.schema_version, RETRACTION_RECORD_V1_SCHEMA)?;
137 ensure_non_empty_id(self.retraction_record_id.as_str(), "retraction_record_id")?;
138 ensure_non_empty_id(self.claim_id.as_str(), "claim_id")?;
139 ensure_non_empty_id(
140 self.retracted_claim_version_id.as_str(),
141 "retracted_claim_version_id",
142 )?;
143 ensure_non_empty(&self.effective_recorded_at, "effective_recorded_at")?;
144 ensure_non_empty(&self.reason, "reason")?;
145 Ok(())
146 }
147}
148
149#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
150pub struct ClaimStateV13 {
151 pub schema_version: String,
152 pub claim_state_id: ClaimStateId,
153 pub claim_id: ClaimId,
154 #[serde(default, skip_serializing_if = "Option::is_none")]
155 pub claim_version_id: Option<ClaimVersionId>,
156 pub semantics_profile_id: SemanticsProfileId,
157 pub view: SemanticViewV1,
158 pub bilattice_truth: BilatticeTruthV1,
159 #[serde(default, skip_serializing_if = "Option::is_none")]
160 pub support_set_id: Option<SupportSetId>,
161 #[serde(default, skip_serializing_if = "Option::is_none")]
162 pub support_set_digest: Option<ContentDigest>,
163 pub quality_vector: QualityVectorV1,
164 pub evidence_admissibility: EvidenceAdmissibilityV1,
165 #[serde(default, skip_serializing_if = "Option::is_none")]
166 pub contradiction_witness_id: Option<ContradictionWitnessId>,
167 #[serde(default, skip_serializing_if = "Option::is_none")]
168 pub valid_from: Option<String>,
169 #[serde(default, skip_serializing_if = "Option::is_none")]
170 pub valid_to: Option<String>,
171 pub tx_from: String,
172 #[serde(default, skip_serializing_if = "Option::is_none")]
173 pub tx_to: Option<String>,
174 #[serde(default, skip_serializing_if = "Vec::is_empty")]
175 pub proof_obligations_remaining: Vec<String>,
176 pub policy_action_allowed: bool,
177}
178
179impl ClaimStateV13 {
180 pub fn validate(&self) -> Result<(), String> {
182 ensure_schema(&self.schema_version, CLAIM_STATE_V13_SCHEMA)?;
183 ensure_non_empty_id(self.claim_state_id.as_str(), "claim_state_id")?;
184 ensure_non_empty_id(self.claim_id.as_str(), "claim_id")?;
185 ensure_non_empty_id(self.semantics_profile_id.as_str(), "semantics_profile_id")?;
186 ensure_non_empty(&self.tx_from, "tx_from")?;
187 if self.support_set_id.is_some() ^ self.support_set_digest.is_some() {
188 return Err(
189 "support_set_id and support_set_digest must either both be present or both be absent"
190 .into(),
191 );
192 }
193 if self.policy_action_allowed
194 && matches!(
195 self.bilattice_truth,
196 BilatticeTruthV1::Unknown | BilatticeTruthV1::FalseOnly | BilatticeTruthV1::Both
197 )
198 {
199 return Err(
200 "policy_action_allowed cannot be true for unknown, false_only, or both truth states"
201 .into(),
202 );
203 }
204 Ok(())
205 }
206}
207
208fn ensure_schema(actual: &str, expected: &str) -> Result<(), String> {
209 if actual == expected {
210 Ok(())
211 } else {
212 Err(format!(
213 "schema_version mismatch: expected {expected}, got {actual}"
214 ))
215 }
216}
217
218fn ensure_non_empty(value: &str, field: &str) -> Result<(), String> {
219 if value.trim().is_empty() {
220 Err(format!("{field} must not be empty"))
221 } else {
222 Ok(())
223 }
224}
225
226fn ensure_non_empty_id(value: &str, field: &str) -> Result<(), String> {
227 ensure_non_empty(value, field)
228}
229
230fn ensure_non_empty_vec<T>(value: &[T], field: &str) -> Result<(), String> {
231 if value.is_empty() {
232 Err(format!("{field} must not be empty"))
233 } else {
234 Ok(())
235 }
236}
237
238fn validate_support_expr(
239 expr: &SupportExprV1,
240 support_tokens: &[SupportTokenV1],
241) -> Result<(), String> {
242 match expr {
243 SupportExprV1::Token { token_id } => {
244 if support_tokens
245 .iter()
246 .any(|token| token.token_id == *token_id)
247 {
248 Ok(())
249 } else {
250 Err(format!(
251 "support_expr references unknown token_id '{token_id}'"
252 ))
253 }
254 }
255 SupportExprV1::AnyOf { children } | SupportExprV1::AllOf { children } => {
256 ensure_non_empty_vec(children, "support_expr.children")?;
257 for child in children {
258 validate_support_expr(child, support_tokens)?;
259 }
260 Ok(())
261 }
262 }
263}
264
265#[cfg(test)]
266mod tests {
267 use super::*;
268
269 #[test]
270 fn support_set_requires_known_tokens() {
271 let support = SupportSetV1 {
272 schema_version: SUPPORT_SET_V1_SCHEMA.into(),
273 support_set_id: SupportSetId::new("support-1"),
274 claim_id: ClaimId::new("claim-1"),
275 semantics_profile_id: SemanticsProfileId::new("profile-1"),
276 support_tokens: vec![SupportTokenV1 {
277 token_id: "tok-1".into(),
278 kind: SupportProvenanceKindV1::EvidenceRef,
279 reference: "evidence:1".into(),
280 polarity: SupportPolarityV1::Supports,
281 }],
282 support_expr: SupportExprV1::Token {
283 token_id: "tok-1".into(),
284 },
285 content_digest: ContentDigest::compute(b"support-set"),
286 };
287
288 assert!(support.validate().is_ok());
289 }
290
291 #[test]
292 fn claim_state_v13_rejects_half_present_support_digest_pair() {
293 let claim_state = ClaimStateV13 {
294 schema_version: CLAIM_STATE_V13_SCHEMA.into(),
295 claim_state_id: ClaimStateId::new("claim-state-1"),
296 claim_id: ClaimId::new("claim-1"),
297 claim_version_id: Some(ClaimVersionId::new("claim-version-1")),
298 semantics_profile_id: SemanticsProfileId::new("profile-1"),
299 view: SemanticViewV1::Canonical,
300 bilattice_truth: BilatticeTruthV1::TrueOnly,
301 support_set_id: Some(SupportSetId::new("support-1")),
302 support_set_digest: None,
303 quality_vector: QualityVectorV1 {
304 exactness: ExactnessLevelV1::Conservative,
305 degradation: vec![DegradationKindV1::ExactnessDowngraded],
306 freshness: Some("current".into()),
307 replay_limited: false,
308 execution_contaminated: false,
309 },
310 evidence_admissibility: EvidenceAdmissibilityV1::Admissible,
311 contradiction_witness_id: None,
312 valid_from: None,
313 valid_to: None,
314 tx_from: "2026-03-14T12:05:00Z".into(),
315 tx_to: None,
316 proof_obligations_remaining: vec![],
317 policy_action_allowed: true,
318 };
319
320 assert!(claim_state.validate().is_err());
321 }
322}