1use crate::{
2 ExtractionReconciliationResult, ExtractionReconciliationStatus, extraction_input_digest,
3};
4use schemars::JsonSchema;
5use serde::{Deserialize, Serialize};
6use serde_json::Value;
7
8pub const EXTRACTION_VERIFICATION_PROTOCOL: &str = "lenso.extraction-verification.v1";
9
10#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
11#[serde(rename_all = "camelCase")]
12pub struct ExtractionBehaviorObservation {
13 pub implementation: String,
14 pub module_id: String,
15 pub operation_id: String,
16 pub tenant_id: String,
17 pub actor_id: String,
18 pub response: Value,
19 pub durable_state: Value,
20 #[serde(default)]
21 pub event_effects: Vec<String>,
22 #[serde(default)]
23 pub workflow_outcomes: Vec<String>,
24 #[serde(default)]
25 pub story_evidence: Vec<String>,
26}
27
28#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
29#[serde(rename_all = "camelCase")]
30pub struct ExtractionCompatibilityEvidence {
31 pub consumer_id: String,
32 pub contract_id: String,
33 pub pinned_version: String,
34 pub compatible: bool,
35 pub detail: String,
36}
37
38impl ExtractionCompatibilityEvidence {
39 #[must_use]
40 pub fn compatible(
41 consumer_id: impl Into<String>,
42 contract_id: impl Into<String>,
43 pinned_version: impl Into<String>,
44 ) -> Self {
45 Self {
46 consumer_id: consumer_id.into(),
47 contract_id: contract_id.into(),
48 pinned_version: pinned_version.into(),
49 compatible: true,
50 detail: "Consumer is compatible with the pinned Contract Version.".to_owned(),
51 }
52 }
53
54 #[must_use]
55 pub fn incompatible(
56 consumer_id: impl Into<String>,
57 contract_id: impl Into<String>,
58 pinned_version: impl Into<String>,
59 detail: impl Into<String>,
60 ) -> Self {
61 Self {
62 consumer_id: consumer_id.into(),
63 contract_id: contract_id.into(),
64 pinned_version: pinned_version.into(),
65 compatible: false,
66 detail: detail.into(),
67 }
68 }
69}
70
71#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
72#[serde(rename_all = "camelCase")]
73pub struct ExtractionPolicyEvidence {
74 pub rule_id: String,
75 pub passed: bool,
76 pub inputs_digest: String,
77 pub detail: String,
78}
79
80impl ExtractionPolicyEvidence {
81 #[must_use]
82 pub fn passed(rule_id: impl Into<String>) -> Self {
83 let rule_id = rule_id.into();
84 Self {
85 inputs_digest: extraction_input_digest(rule_id.as_bytes()),
86 rule_id,
87 passed: true,
88 detail: "Built-in extraction safety rule passed.".to_owned(),
89 }
90 }
91
92 #[must_use]
93 pub fn failed(rule_id: impl Into<String>, detail: impl Into<String>) -> Self {
94 let rule_id = rule_id.into();
95 Self {
96 inputs_digest: extraction_input_digest(rule_id.as_bytes()),
97 rule_id,
98 passed: false,
99 detail: detail.into(),
100 }
101 }
102}
103
104#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
105#[serde(rename_all = "camelCase")]
106pub struct ExtractionVerificationInputs {
107 pub reconciliation: ExtractionReconciliationResult,
108 pub linked: ExtractionBehaviorObservation,
109 pub candidate: ExtractionBehaviorObservation,
110 #[serde(default)]
111 pub compatibility: Vec<ExtractionCompatibilityEvidence>,
112 #[serde(default)]
113 pub policy: Vec<ExtractionPolicyEvidence>,
114 #[serde(default)]
115 pub volatile_json_pointers: Vec<String>,
116}
117
118#[derive(
119 Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, JsonSchema,
120)]
121#[serde(rename_all = "snake_case")]
122pub enum ExtractionVerificationStatus {
123 Verified,
124 Blocked,
125}
126
127#[derive(
128 Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, JsonSchema,
129)]
130#[serde(rename_all = "snake_case")]
131pub enum ExtractionVerificationIssueCode {
132 ReconciliationNotMatched,
133 BehaviorMismatch,
134 DurableStateMismatch,
135 EventEffectMismatch,
136 WorkflowOutcomeMismatch,
137 ContextMismatch,
138 StoryMismatch,
139 ConsumerIncompatible,
140 PolicyRejected,
141}
142
143#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, JsonSchema)]
144#[serde(rename_all = "camelCase")]
145pub struct ExtractionVerificationIssue {
146 pub code: ExtractionVerificationIssueCode,
147 pub subject: String,
148 pub detail: String,
149 pub next_actions: Vec<String>,
150}
151
152#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, JsonSchema)]
153#[serde(rename_all = "camelCase")]
154pub struct ExtractionVerificationEvidence {
155 pub kind: String,
156 pub subject: String,
157 pub digest: String,
158 pub detail: String,
159}
160
161#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
162#[serde(rename_all = "camelCase")]
163pub struct ExtractionVerificationEffects {
164 pub invokes_linked_public_contract: bool,
165 pub invokes_candidate_public_contract: bool,
166 pub routes_external_mutations: bool,
167 pub changes_authority: bool,
168 pub requires_runtime_console: bool,
169 pub requires_system_plane_for_business_execution: bool,
170}
171
172#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
173#[serde(rename_all = "camelCase")]
174pub struct ExtractionVerificationResult {
175 pub protocol: String,
176 pub verification_id: String,
177 pub verification_digest: String,
178 pub status: ExtractionVerificationStatus,
179 pub plan_id: String,
180 pub reconciliation_id: String,
181 pub reconciliation_digest: String,
182 pub issues: Vec<ExtractionVerificationIssue>,
183 pub evidence: Vec<ExtractionVerificationEvidence>,
184 pub compatibility: Vec<ExtractionCompatibilityEvidence>,
185 pub policy: Vec<ExtractionPolicyEvidence>,
186 pub volatile_json_pointers: Vec<String>,
187 pub provisional_cutover_eligible: bool,
188 pub linked_authority_remains_authoritative: bool,
189 pub effects: ExtractionVerificationEffects,
190}
191
192#[must_use]
193pub fn verify_extraction_behavior(
194 mut inputs: ExtractionVerificationInputs,
195) -> ExtractionVerificationResult {
196 inputs.compatibility.sort_by(|a, b| {
197 (&a.consumer_id, &a.contract_id, &a.pinned_version).cmp(&(
198 &b.consumer_id,
199 &b.contract_id,
200 &b.pinned_version,
201 ))
202 });
203 inputs.policy.sort_by(|a, b| a.rule_id.cmp(&b.rule_id));
204 inputs.volatile_json_pointers.sort();
205 inputs.volatile_json_pointers.dedup();
206 let mut issues = Vec::new();
207 let mut evidence = Vec::new();
208 if inputs.reconciliation.status != ExtractionReconciliationStatus::Matched {
209 issue(
210 &mut issues,
211 ExtractionVerificationIssueCode::ReconciliationNotMatched,
212 "reconciliation",
213 "Data reconciliation is not matched.",
214 "Remediate reconciliation blockers before behavior verification.",
215 );
216 }
217 if inputs.linked.module_id != inputs.candidate.module_id
218 || inputs.linked.operation_id != inputs.candidate.operation_id
219 {
220 issue(
221 &mut issues,
222 ExtractionVerificationIssueCode::BehaviorMismatch,
223 "business-identity",
224 "Module or operation identity changed between implementations.",
225 "Preserve the declared Module and operation identities.",
226 );
227 }
228 if inputs.linked.tenant_id != inputs.candidate.tenant_id
229 || inputs.linked.actor_id != inputs.candidate.actor_id
230 {
231 issue(
232 &mut issues,
233 ExtractionVerificationIssueCode::ContextMismatch,
234 "actor-tenant-context",
235 "Actor or tenant scope changed between implementations.",
236 "Preserve verified actor and tenant context at the Service boundary.",
237 );
238 }
239 let linked_response = normalize(
240 inputs.linked.response.clone(),
241 &inputs.volatile_json_pointers,
242 );
243 let candidate_response = normalize(
244 inputs.candidate.response.clone(),
245 &inputs.volatile_json_pointers,
246 );
247 compare_value(
248 &mut issues,
249 ExtractionVerificationIssueCode::BehaviorMismatch,
250 "response",
251 &linked_response,
252 &candidate_response,
253 );
254 let linked_state = normalize(
255 inputs.linked.durable_state.clone(),
256 &inputs.volatile_json_pointers,
257 );
258 let candidate_state = normalize(
259 inputs.candidate.durable_state.clone(),
260 &inputs.volatile_json_pointers,
261 );
262 compare_value(
263 &mut issues,
264 ExtractionVerificationIssueCode::DurableStateMismatch,
265 "durable-state",
266 &linked_state,
267 &candidate_state,
268 );
269 compare_list(
270 &mut issues,
271 ExtractionVerificationIssueCode::EventEffectMismatch,
272 "event-effects",
273 &inputs.linked.event_effects,
274 &inputs.candidate.event_effects,
275 );
276 compare_list(
277 &mut issues,
278 ExtractionVerificationIssueCode::WorkflowOutcomeMismatch,
279 "workflow-outcomes",
280 &inputs.linked.workflow_outcomes,
281 &inputs.candidate.workflow_outcomes,
282 );
283 compare_list(
284 &mut issues,
285 ExtractionVerificationIssueCode::StoryMismatch,
286 "runtime-stories",
287 &inputs.linked.story_evidence,
288 &inputs.candidate.story_evidence,
289 );
290 for compatibility in &inputs.compatibility {
291 evidence.push(evidence_for(
292 "consumer_compatibility",
293 &compatibility.consumer_id,
294 compatibility,
295 &compatibility.detail,
296 ));
297 if !compatibility.compatible {
298 issue(
299 &mut issues,
300 ExtractionVerificationIssueCode::ConsumerIncompatible,
301 &compatibility.consumer_id,
302 &compatibility.detail,
303 "Restore compatibility with the pinned active Consumer Contract Version.",
304 );
305 }
306 }
307 for policy in &inputs.policy {
308 evidence.push(evidence_for(
309 "policy",
310 &policy.rule_id,
311 policy,
312 &policy.detail,
313 ));
314 if !policy.passed {
315 issue(
316 &mut issues,
317 ExtractionVerificationIssueCode::PolicyRejected,
318 &policy.rule_id,
319 &policy.detail,
320 "Apply the rule-specific remediation and rerun verification.",
321 );
322 }
323 }
324 evidence.push(evidence_for(
325 "behavior_comparison",
326 &inputs.linked.operation_id,
327 &(
328 linked_response,
329 candidate_response,
330 linked_state,
331 candidate_state,
332 &inputs.linked.event_effects,
333 &inputs.candidate.event_effects,
334 &inputs.linked.workflow_outcomes,
335 &inputs.candidate.workflow_outcomes,
336 &inputs.linked.story_evidence,
337 &inputs.candidate.story_evidence,
338 ),
339 "Linked and candidate observations were compared through public contracts.",
340 ));
341 issues.sort();
342 evidence.sort();
343 let status = if issues.is_empty() {
344 ExtractionVerificationStatus::Verified
345 } else {
346 ExtractionVerificationStatus::Blocked
347 };
348 let identity_digest = digest(&(
349 inputs.reconciliation.reconciliation_id.as_str(),
350 inputs.reconciliation.reconciliation_digest.as_str(),
351 inputs.linked.operation_id.as_str(),
352 inputs.linked.tenant_id.as_str(),
353 inputs.linked.actor_id.as_str(),
354 ));
355 let mut result = ExtractionVerificationResult {
356 protocol: EXTRACTION_VERIFICATION_PROTOCOL.to_owned(),
357 verification_id: format!("extraction-verification:{identity_digest}"),
358 verification_digest: String::new(),
359 status,
360 plan_id: inputs.reconciliation.plan_id,
361 reconciliation_id: inputs.reconciliation.reconciliation_id,
362 reconciliation_digest: inputs.reconciliation.reconciliation_digest,
363 issues,
364 evidence,
365 compatibility: inputs.compatibility,
366 policy: inputs.policy,
367 volatile_json_pointers: inputs.volatile_json_pointers,
368 provisional_cutover_eligible: status == ExtractionVerificationStatus::Verified,
369 linked_authority_remains_authoritative: true,
370 effects: ExtractionVerificationEffects {
371 invokes_linked_public_contract: true,
372 invokes_candidate_public_contract: true,
373 ..ExtractionVerificationEffects::default()
374 },
375 };
376 result.verification_digest = digest(&without_digest(&result));
377 result
378}
379
380fn compare_value(
381 issues: &mut Vec<ExtractionVerificationIssue>,
382 code: ExtractionVerificationIssueCode,
383 subject: &str,
384 linked: &Value,
385 candidate: &Value,
386) {
387 if linked != candidate {
388 issue(
389 issues,
390 code,
391 subject,
392 format!("Linked and candidate {subject} differ."),
393 format!("Inspect the declared {subject} difference and rerun verification."),
394 );
395 }
396}
397
398fn compare_list(
399 issues: &mut Vec<ExtractionVerificationIssue>,
400 code: ExtractionVerificationIssueCode,
401 subject: &str,
402 linked: &[String],
403 candidate: &[String],
404) {
405 if linked != candidate {
406 issue(
407 issues,
408 code,
409 subject,
410 format!("Linked and candidate {subject} differ."),
411 format!("Preserve declared {subject} identities and business effects."),
412 );
413 }
414}
415
416fn normalize(mut value: Value, pointers: &[String]) -> Value {
417 for pointer in pointers {
418 if let Some((parent, key)) = pointer.rsplit_once('/') {
419 if let Some(Value::Object(object)) = value.pointer_mut(parent) {
420 object.remove(key);
421 }
422 }
423 }
424 value
425}
426
427fn issue(
428 issues: &mut Vec<ExtractionVerificationIssue>,
429 code: ExtractionVerificationIssueCode,
430 subject: impl Into<String>,
431 detail: impl Into<String>,
432 next_action: impl Into<String>,
433) {
434 issues.push(ExtractionVerificationIssue {
435 code,
436 subject: subject.into(),
437 detail: detail.into(),
438 next_actions: vec![next_action.into()],
439 });
440}
441
442fn evidence_for(
443 kind: &str,
444 subject: &str,
445 value: &impl Serialize,
446 detail: &str,
447) -> ExtractionVerificationEvidence {
448 ExtractionVerificationEvidence {
449 kind: kind.to_owned(),
450 subject: subject.to_owned(),
451 digest: digest(value),
452 detail: detail.to_owned(),
453 }
454}
455
456fn digest(value: &impl Serialize) -> String {
457 extraction_input_digest(
458 &serde_json::to_vec(value).expect("Extraction verification values must serialize"),
459 )
460}
461
462fn without_digest(result: &ExtractionVerificationResult) -> ExtractionVerificationResult {
463 let mut value = result.clone();
464 value.verification_digest.clear();
465 value
466}
467
468#[must_use]
469pub fn extraction_verification_integrity_is_valid(result: &ExtractionVerificationResult) -> bool {
470 result.protocol == EXTRACTION_VERIFICATION_PROTOCOL
471 && result.verification_digest == digest(&without_digest(result))
472}