Skip to main content

lenso_service/
extraction_verification.rs

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}