Skip to main content

termivar_scanner/web_runtime/
rest_runtime.rs

1//! Parent-native, explicitly enabled REST read-only surface observation.
2//!
3//! This module owns no transport authority. The binding installs one executor
4//! into the parent decision registry and every wire leg is dispatched through
5//! the parent assessment's exact-origin request broker and runtime budget.
6
7use std::{
8    collections::BTreeMap,
9    fmt,
10    sync::{Arc, Mutex},
11};
12
13use async_trait::async_trait;
14use serde::Serialize;
15use sha2::{Digest, Sha256};
16use termivar_core::{
17    ApiSurfaceKind, ConfidenceScore, EntityId, Evidence, EvidenceId, EvidenceKind, EvidenceSource,
18    EvidenceValue, HttpEvidencePredicate, KnowledgePredicate,
19};
20
21use super::{
22    assessment_defense::{project_assessment_defense_signal, AssessmentDefenseProjectionContext},
23    assessment_item::{
24        AssessmentCapabilityDescriptor, AssessmentItemProjectionError, AssessmentItemTarget,
25        AssessmentProjectionContext,
26    },
27};
28use crate::api_evidence::{ApiExactReplayComparison, ApiVisibilityEvidenceError};
29use crate::{http_evidence::HttpRequestBroker, http_evidence::HttpRequestBrokerError};
30use crate::{
31    rest_review::{RestDocumentedResponseClass, RestOperationSelection},
32    ApiVisibilityComparator, ApiVisibilityView, DecisionActionExecutor, DecisionActionOrigin,
33    DecisionExecutionRequest, DecisionExecutionStage, DecisionExecutorError,
34    DecisionExecutorRegistry, HttpProbe, HttpProbeMethod, KnowledgeBase, RuntimeLimitExceeded,
35    TransportDispatchOutcome,
36};
37
38pub const REST_REVIEW_ACTION_ID: &str = "web.review.rest.readonly-replay@1";
39pub const REST_REVIEW_CAPABILITY_ID: &str = "api.rest-readonly-surface-observed@1";
40pub const MAX_REST_REVIEW_RESOURCES: usize = 1;
41pub const MAX_REST_REVIEW_REQUESTS: usize = 2;
42pub const MAX_REST_REVIEW_ACTIVE_VERIFICATIONS: usize = 1;
43pub(super) const REST_REVIEW_ACTION_CYCLE_ALLOWANCE: u32 = 1;
44
45const REST_REVIEW_EXECUTOR_ID: &str = "http.rest-review";
46const REST_REVIEW_EVIDENCE_NAMESPACE: &str = "web.rest-review.transport";
47const REST_REVIEW_ACCEPT: &str = "application/json";
48const REST_REVIEW_BODY_DIGEST_DOMAIN: &[u8] = b"security.rest-review.body-receipt.v1\0";
49const REST_CLASSIFICATION_ALGORITHM: &str = "web.rest-review.value-free-classification";
50
51#[derive(Clone, Copy)]
52enum RestResponsePrivacy {
53    HistoricalDigests,
54    #[cfg(feature = "secret-exposure-review")]
55    ValueFree,
56}
57
58impl RestResponsePrivacy {
59    fn is_value_free(self) -> bool {
60        match self {
61            Self::HistoricalDigests => false,
62            #[cfg(feature = "secret-exposure-review")]
63            Self::ValueFree => true,
64        }
65    }
66}
67
68#[cfg(feature = "secret-exposure-review")]
69pub(super) fn rest_privacy_route_is_valid(
70    case: &crate::VerificationCase,
71    stage: DecisionExecutionStage,
72    executor: &str,
73) -> bool {
74    executor == REST_REVIEW_EXECUTOR_ID
75        && case.action_id() == REST_REVIEW_ACTION_ID
76        && !case.applies_hypothesis_transition()
77        && case.payload_strategy().is_none()
78        && matches!(
79            stage,
80            DecisionExecutionStage::Passive | DecisionExecutionStage::Active
81        )
82}
83
84const REST_REVIEW_CAPABILITY: AssessmentCapabilityDescriptor =
85    AssessmentCapabilityDescriptor::informational(
86        REST_REVIEW_CAPABILITY_ID,
87        "REST operation surface observed",
88        "API surface",
89        "Two anonymous exact-origin GET requests reproduced the same bounded JSON resource structure.",
90        900_000,
91        "api.rest-readonly-surface-review@1",
92        "Confirm that anonymously exposing this documented read-only operation matches deployment policy.",
93    );
94
95/// Closed runtime outcome for one selected read-only REST operation.
96#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize)]
97#[serde(rename_all = "snake_case")]
98pub enum RestRuntimeOutcome {
99    NotEligible,
100    SurfaceObserved,
101    ReplayMismatch,
102    CompleteNonJson,
103    Redirect,
104    AuthenticationRequired,
105    Forbidden,
106    NotFound,
107    RateLimited,
108    DefensiveInterference,
109    ServerError,
110    UnsupportedMedia,
111    Truncated,
112    Incomplete,
113    Cancelled,
114    BudgetExhausted,
115}
116
117/// Reduced media class retained by the public assessment audit.
118#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize)]
119#[serde(rename_all = "snake_case")]
120pub enum RestObservedMediaClass {
121    JsonCompatible,
122    Text,
123    Unsupported,
124    Unknown,
125}
126
127/// Bounded, raw-value-free audit for the optional REST child.
128#[derive(Clone, Eq, PartialEq, Serialize)]
129pub struct WebAssessmentRestAudit {
130    outcome: RestRuntimeOutcome,
131    request_count: u8,
132    active_verification_count: u8,
133    eligible_operation_count: u32,
134    selected_operation_identity: Option<String>,
135    documented_response: Option<RestDocumentedResponseClass>,
136    observed_media: RestObservedMediaClass,
137    status_class: Option<u8>,
138    replay_stable: bool,
139    item_projected: bool,
140}
141
142impl WebAssessmentRestAudit {
143    pub const fn outcome(&self) -> RestRuntimeOutcome {
144        self.outcome
145    }
146
147    pub const fn request_count(&self) -> u8 {
148        self.request_count
149    }
150
151    pub const fn active_verification_count(&self) -> u8 {
152        self.active_verification_count
153    }
154
155    pub const fn eligible_operation_count(&self) -> u32 {
156        self.eligible_operation_count
157    }
158
159    pub fn selected_operation_identity(&self) -> Option<&str> {
160        self.selected_operation_identity.as_deref()
161    }
162
163    pub const fn documented_response(&self) -> Option<RestDocumentedResponseClass> {
164        self.documented_response
165    }
166
167    pub const fn observed_media(&self) -> RestObservedMediaClass {
168        self.observed_media
169    }
170
171    pub const fn status_class(&self) -> Option<u8> {
172        self.status_class
173    }
174
175    pub const fn replay_stable(&self) -> bool {
176        self.replay_stable
177    }
178
179    pub const fn item_projected(&self) -> bool {
180        self.item_projected
181    }
182}
183
184impl fmt::Debug for WebAssessmentRestAudit {
185    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
186        formatter
187            .debug_struct("WebAssessmentRestAudit")
188            .field("outcome", &self.outcome)
189            .field("request_count", &self.request_count)
190            .field("active_verification_count", &self.active_verification_count)
191            .field("eligible_operation_count", &self.eligible_operation_count)
192            .field(
193                "selected_operation_identity",
194                &self
195                    .selected_operation_identity
196                    .as_ref()
197                    .map(|_| "<stable-digest>"),
198            )
199            .field("documented_response", &self.documented_response)
200            .field("observed_media", &self.observed_media)
201            .field("status_class", &self.status_class)
202            .field("replay_stable", &self.replay_stable)
203            .field("item_projected", &self.item_projected)
204            .finish()
205    }
206}
207
208/// Single-assignment handoff populated only after OpenAPI candidate/replay
209/// agreement. Clones share the same slot; no clone grants transport authority.
210#[derive(Clone, Default)]
211pub(super) struct StableRestSelectionSlot {
212    selection: Arc<Mutex<Option<RestOperationSelection>>>,
213}
214
215impl StableRestSelectionSlot {
216    pub(super) fn new() -> Self {
217        Self::default()
218    }
219
220    pub(super) fn commit(
221        &self,
222        selection: RestOperationSelection,
223    ) -> Result<(), RestRuntimeInvariantError> {
224        let mut slot = self
225            .selection
226            .lock()
227            .map_err(|_| RestRuntimeInvariantError::Catalog)?;
228        if slot.is_some() {
229            return Err(RestRuntimeInvariantError::Catalog);
230        }
231        *slot = Some(selection);
232        Ok(())
233    }
234
235    pub(super) fn selection(
236        &self,
237    ) -> Result<Option<RestOperationSelection>, RestRuntimeInvariantError> {
238        self.selection
239            .lock()
240            .map_err(|_| RestRuntimeInvariantError::Catalog)
241            .map(|selection| selection.clone())
242    }
243}
244
245impl fmt::Debug for StableRestSelectionSlot {
246    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
247        formatter.write_str("StableRestSelectionSlot(<redacted-single-assignment>)")
248    }
249}
250
251/// Parent-owned binding for one optional REST action.
252pub(super) struct RestReviewBinding {
253    executor: Arc<RestDecisionExecutor>,
254    subject: EntityId,
255}
256
257impl RestReviewBinding {
258    pub(super) fn new(
259        selection: StableRestSelectionSlot,
260        requests: HttpRequestBroker,
261        subject: EntityId,
262        value_free: bool,
263    ) -> Self {
264        let privacy = RestResponsePrivacy::HistoricalDigests;
265        #[cfg(feature = "secret-exposure-review")]
266        let privacy = if value_free {
267            RestResponsePrivacy::ValueFree
268        } else {
269            privacy
270        };
271        #[cfg(not(feature = "secret-exposure-review"))]
272        debug_assert!(!value_free);
273        let executor = Arc::new(RestDecisionExecutor {
274            requests,
275            selection,
276            subject: subject.clone(),
277            privacy,
278            state: Mutex::new(RestExecutionState::default()),
279        });
280        Self { executor, subject }
281    }
282
283    pub(super) fn install_into_parent_registry(
284        &self,
285        registry: &mut DecisionExecutorRegistry,
286    ) -> Result<(), RestRuntimeInvariantError> {
287        let before = registry.len();
288        let executor: Arc<dyn DecisionActionExecutor> = self.executor.clone();
289        registry
290            .register(executor)
291            .map_err(|_| RestRuntimeInvariantError::Catalog)?;
292        for stage in [
293            DecisionExecutionStage::Passive,
294            DecisionExecutionStage::Active,
295        ] {
296            registry
297                .route_action(stage, REST_REVIEW_ACTION_ID, REST_REVIEW_EXECUTOR_ID)
298                .map_err(|_| RestRuntimeInvariantError::Catalog)?;
299        }
300        if registry.len() != before + 1 {
301            return Err(RestRuntimeInvariantError::Catalog);
302        }
303        Ok(())
304    }
305
306    pub(super) fn finalize(
307        self,
308        knowledge: &KnowledgeBase,
309        standard: &super::StandardWebDecisionRunReport,
310        forced_outcome: Option<RestRuntimeOutcome>,
311        forced_runtime_limit: Option<RuntimeLimitExceeded>,
312    ) -> Result<RestRuntimeResult, RestRuntimeInvariantError> {
313        let transport = standard.transport();
314        if transport.omitted_receipt_count() != 0 {
315            return Err(RestRuntimeInvariantError::Catalog);
316        }
317        let selection = self.executor.selection.selection()?;
318        let receipts = transport
319            .receipts()
320            .iter()
321            .filter(|receipt| receipt.action_id() == REST_REVIEW_ACTION_ID)
322            .collect::<Vec<_>>();
323        if !rest_transport_prefix_is_valid(&receipts) {
324            return Err(RestRuntimeInvariantError::Catalog);
325        }
326        let state = self.executor.take_state()?;
327        #[cfg(feature = "secret-exposure-review")]
328        let committed = if self.executor.privacy.is_value_free() {
329            validate_value_free_rest_commits(
330                standard,
331                knowledge,
332                &self.subject,
333                selection.as_ref(),
334                &state,
335            )?
336        } else {
337            true
338        };
339        #[cfg(not(feature = "secret-exposure-review"))]
340        let committed = true;
341        if !captured_rest_prefix_reconciles(&state, &receipts) {
342            return Err(RestRuntimeInvariantError::Catalog);
343        }
344        let request_count = u8::try_from(receipts.len()).unwrap_or(u8::MAX);
345        let terminal = forced_outcome.or(if committed {
346            state.terminal
347        } else {
348            Some(RestRuntimeOutcome::Incomplete)
349        });
350        let runtime_limit = if forced_outcome.is_some() {
351            forced_runtime_limit
352        } else {
353            state.runtime_limit
354        };
355
356        if let Some(outcome) = terminal {
357            let audit = audit_for_privacy(
358                selection.as_ref(),
359                outcome,
360                request_count,
361                state.terminal_observation,
362                false,
363                self.executor.privacy,
364            );
365            if matches!(
366                outcome,
367                RestRuntimeOutcome::Truncated
368                    | RestRuntimeOutcome::Incomplete
369                    | RestRuntimeOutcome::BudgetExhausted
370                    | RestRuntimeOutcome::Cancelled
371            ) {
372                return Ok(RestRuntimeResult::Stopped {
373                    audit,
374                    runtime_limit,
375                });
376            }
377            return Ok(RestRuntimeResult::Complete(CommittedRestReview {
378                subject: self.subject,
379                privacy: self.executor.privacy,
380                target_identity: selection
381                    .as_ref()
382                    .map(|selected| selected.target_identity().to_owned()),
383                outcome,
384                evidence_ids: state
385                    .legs
386                    .values()
387                    .flat_map(|leg| leg.evidence_ids.iter().cloned())
388                    .collect(),
389                audit,
390            }));
391        }
392
393        let Some(selection) = selection else {
394            return Ok(RestRuntimeResult::Complete(CommittedRestReview {
395                subject: self.subject,
396                privacy: self.executor.privacy,
397                target_identity: None,
398                outcome: RestRuntimeOutcome::NotEligible,
399                evidence_ids: Vec::new(),
400                audit: audit_for_privacy(
401                    None,
402                    RestRuntimeOutcome::NotEligible,
403                    request_count,
404                    None,
405                    false,
406                    self.executor.privacy,
407                ),
408            }));
409        };
410        let (Some(candidate), Some(replay)) = (
411            state.legs.get(&DecisionExecutionStage::Passive),
412            state.legs.get(&DecisionExecutionStage::Active),
413        ) else {
414            return Ok(RestRuntimeResult::Stopped {
415                audit: audit_for_privacy(
416                    Some(&selection),
417                    RestRuntimeOutcome::Incomplete,
418                    request_count,
419                    None,
420                    false,
421                    self.executor.privacy,
422                ),
423                runtime_limit: None,
424            });
425        };
426        if receipts.len() != MAX_REST_REVIEW_REQUESTS
427            || receipts
428                .iter()
429                .zip([candidate, replay])
430                .any(|(receipt, leg)| {
431                    receipt.outcome() != TransportDispatchOutcome::Completed
432                        || receipt.response_bytes() != leg.response_bytes
433                })
434        {
435            return Ok(RestRuntimeResult::Stopped {
436                audit: audit_for_privacy(
437                    Some(&selection),
438                    RestRuntimeOutcome::Incomplete,
439                    request_count,
440                    None,
441                    false,
442                    self.executor.privacy,
443                ),
444                runtime_limit: None,
445            });
446        }
447
448        let comparison = ApiVisibilityComparator::default()
449            .compare_exact_replay(&candidate.view, &replay.view)
450            .map_err(|_| RestRuntimeInvariantError::Catalog)?;
451        let projected = comparison.all_equivalent();
452        let outcome = if projected {
453            RestRuntimeOutcome::SurfaceObserved
454        } else {
455            RestRuntimeOutcome::ReplayMismatch
456        };
457        let evidence_ids = state
458            .legs
459            .values()
460            .flat_map(|leg| leg.evidence_ids.iter().cloned())
461            .collect::<Vec<_>>();
462        if evidence_ids.is_empty()
463            || evidence_ids
464                .iter()
465                .any(|evidence_id| knowledge.evidence(evidence_id).is_none())
466        {
467            return Err(RestRuntimeInvariantError::Catalog);
468        }
469        let audit = audit_for_privacy(
470            Some(&selection),
471            outcome,
472            request_count,
473            Some(replay.audit_observation()),
474            projected,
475            self.executor.privacy,
476        );
477        Ok(RestRuntimeResult::Complete(CommittedRestReview {
478            subject: self.subject,
479            privacy: self.executor.privacy,
480            target_identity: Some(selection.target_identity().to_owned()),
481            outcome,
482            evidence_ids,
483            audit,
484        }))
485    }
486}
487
488/// Staged views are not projection authority until the normal runner's exact
489/// write-set and closed response lineage have committed to this assessment.
490#[cfg(feature = "secret-exposure-review")]
491fn validate_value_free_rest_commits(
492    standard: &super::StandardWebDecisionRunReport,
493    knowledge: &KnowledgeBase,
494    subject: &EntityId,
495    selection: Option<&RestOperationSelection>,
496    state: &RestExecutionState,
497) -> Result<bool, RestRuntimeInvariantError> {
498    use crate::{KnowledgeWrite, StandardWebDecisionRuntimeTurn};
499    use std::collections::BTreeSet;
500    let receipts = standard
501        .turns()
502        .iter()
503        .filter_map(|turn| match turn {
504            StandardWebDecisionRuntimeTurn::Outcome { evidence, .. } => Some(evidence.as_ref()),
505            StandardWebDecisionRuntimeTurn::Planning(_) => None,
506        })
507        .chain(standard.unverified_evidence())
508        .filter(|receipt| {
509            receipt.case().action_id() == REST_REVIEW_ACTION_ID
510                || receipt.executor_id() == REST_REVIEW_EXECUTOR_ID
511        })
512        .collect::<Vec<_>>();
513    if receipts.len() > MAX_REST_REVIEW_REQUESTS {
514        return Err(RestRuntimeInvariantError::Catalog);
515    }
516    let mut committed = BTreeSet::new();
517    let mut terminal_committed = false;
518    for (index, receipt) in receipts.into_iter().enumerate() {
519        let expected_stage = if index == 0 {
520            DecisionExecutionStage::Passive
521        } else {
522            DecisionExecutionStage::Active
523        };
524        if !rest_privacy_route_is_valid(receipt.case(), receipt.stage(), receipt.executor_id())
525            || receipt.case().subject() != subject
526            || receipt.stage() != expected_stage
527            || receipt.evidence().len() != receipt.writes().len()
528        {
529            return Err(RestRuntimeInvariantError::Catalog);
530        }
531        let mut ids = BTreeSet::new();
532        for (item, write) in receipt.write_set() {
533            if !matches!(write, KnowledgeWrite::Inserted | KnowledgeWrite::Unchanged)
534                || item.subject() != subject
535                || item.source().component() != REST_REVIEW_EXECUTOR_ID
536                || item.source().correlation_id() != Some(receipt.case().id())
537                || item.reliability() != ConfidenceScore::MAX
538                || !ids.insert(item.id())
539                || knowledge.evidence(item.id()).as_ref() != Some(item)
540                || item.predicate() == &HttpEvidencePredicate::RESPONSE_BODY_SHA256.into_knowledge()
541            {
542                return Err(RestRuntimeInvariantError::Catalog);
543            }
544        }
545        let phase = rest_receipt_fact(
546            receipt,
547            &crate::web_actions::rest_review_phase_terminal_predicate(),
548        )?;
549        if phase.kind() != &EvidenceKind::Custom("rest-review".to_owned())
550            || !phase.origin().is_direct()
551            || phase.source().method() != "phase-terminal"
552        {
553            return Err(RestRuntimeInvariantError::Catalog);
554        }
555        let response_received = receipt.evidence().iter().any(|item| {
556            item.predicate() == &HttpEvidencePredicate::REQUEST_METHOD.into_knowledge()
557        });
558        if !response_received {
559            let bytes = rest_receipt_fact(
560                receipt,
561                &HttpEvidencePredicate::RESPONSE_BODY_BYTES_OBSERVED.into_knowledge(),
562            )?;
563            if state.terminal.is_none()
564                || state.legs.contains_key(&receipt.stage())
565                || receipt.evidence().len() != 2
566                || bytes.kind() != &EvidenceKind::Custom("rest-review".to_owned())
567                || !bytes.origin().is_direct()
568                || bytes.source().method() != "response-body-size"
569                || bytes.value() != &EvidenceValue::Unsigned(0)
570                || phase.value() != &EvidenceValue::Boolean(true)
571            {
572                return Err(RestRuntimeInvariantError::Catalog);
573            }
574            terminal_committed = true;
575            continue;
576        }
577        let selected = selection.ok_or(RestRuntimeInvariantError::Catalog)?;
578        let parents = super::secret_exposure::rest_response_privacy_parent_ids(
579            receipt.evidence(),
580            receipt.case(),
581            receipt.stage(),
582            receipt.executor_id(),
583        )
584        .ok_or(RestRuntimeInvariantError::Catalog)?;
585        let lineage_predicate = KnowledgePredicate::new(
586            super::secret_exposure::SECRET_EXPOSURE_NAMESPACE,
587            super::secret_exposure::ACTIVE_REVIEW_BODY_LINEAGE,
588        )
589        .map_err(|_| RestRuntimeInvariantError::Catalog)?;
590        let lineage = rest_receipt_fact(receipt, &lineage_predicate)?;
591        if !super::secret_exposure::is_active_review_body_privacy_lineage(
592            lineage,
593            subject,
594            receipt.case().id(),
595            REST_REVIEW_EXECUTOR_ID,
596            &parents,
597        ) {
598            return Err(RestRuntimeInvariantError::Catalog);
599        }
600        for predicate in [
601            HttpEvidencePredicate::REQUEST_URL,
602            HttpEvidencePredicate::RESPONSE_FINAL_URL,
603        ] {
604            if rest_receipt_fact(receipt, &predicate.into_knowledge())?.value()
605                != &EvidenceValue::Text(selected.execution_url().to_string())
606            {
607                return Err(RestRuntimeInvariantError::Catalog);
608            }
609        }
610        let fixed_predicates = [
611            HttpEvidencePredicate::REQUEST_METHOD,
612            HttpEvidencePredicate::REQUEST_URL,
613            HttpEvidencePredicate::RESPONSE_STATUS,
614            HttpEvidencePredicate::RESPONSE_FINAL_URL,
615            HttpEvidencePredicate::RESPONSE_BODY_BYTES_OBSERVED,
616            HttpEvidencePredicate::RESPONSE_BODY_TRUNCATED,
617            HttpEvidencePredicate::RATE_LIMIT_DETECTED,
618            HttpEvidencePredicate::RATE_LIMIT_ADVERTISED,
619            HttpEvidencePredicate::RESPONSE_MEDIA_TYPE,
620            HttpEvidencePredicate::RESPONSE_MEDIA_TYPE_JSON_COMPATIBLE,
621        ]
622        .map(|predicate| predicate.into_knowledge());
623        for (predicate, kind, method) in [
624            (
625                HttpEvidencePredicate::RATE_LIMIT_DETECTED,
626                EvidenceKind::RateLimit,
627                "rate-limit-status",
628            ),
629            (
630                HttpEvidencePredicate::RATE_LIMIT_ADVERTISED,
631                EvidenceKind::RateLimit,
632                "rate-limit-headers",
633            ),
634        ] {
635            let item = rest_receipt_fact(receipt, &predicate.into_knowledge())?;
636            if item.kind() != &kind
637                || !item.origin().is_direct()
638                || item.source().method() != method
639                || !matches!(item.value(), EvidenceValue::Boolean(_))
640            {
641                return Err(RestRuntimeInvariantError::Catalog);
642            }
643        }
644        let media_predicate = HttpEvidencePredicate::RESPONSE_MEDIA_TYPE.into_knowledge();
645        let compatibility_predicate =
646            HttpEvidencePredicate::RESPONSE_MEDIA_TYPE_JSON_COMPATIBLE.into_knowledge();
647        let media = receipt
648            .evidence()
649            .iter()
650            .find(|item| item.predicate() == &media_predicate);
651        let mut compatibility = receipt
652            .evidence()
653            .iter()
654            .filter(|item| item.predicate() == &compatibility_predicate);
655        match (media, compatibility.next()) {
656            (Some(media), Some(compatible)) => {
657                let EvidenceValue::Text(media_type) = media.value() else {
658                    return Err(RestRuntimeInvariantError::Catalog);
659                };
660                if compatibility.next().is_some()
661                    || compatible.kind() != &EvidenceKind::Http
662                    || !compatible.origin().is_direct()
663                    || compatible.source().method() != "response-media-type-json-compatibility"
664                    || compatible.value()
665                        != &EvidenceValue::Boolean(
666                            crate::http_evidence::json_compatible_media_type(media_type),
667                        )
668                {
669                    return Err(RestRuntimeInvariantError::Catalog);
670                }
671            },
672            (None, None) => {},
673            _ => return Err(RestRuntimeInvariantError::Catalog),
674        }
675        if receipt.evidence().iter().any(|item| {
676            let rest_fact = item.predicate().namespace() == REST_REVIEW_EVIDENCE_NAMESPACE
677                && matches!(
678                    item.predicate().name(),
679                    "view"
680                        | "phase-terminal"
681                        | "status-equivalent"
682                        | "fields-equivalent"
683                        | "resources-equivalent"
684                );
685            let known_fact = fixed_predicates.contains(item.predicate())
686                || rest_fact
687                || item.predicate() == &lineage_predicate
688                || item.predicate()
689                    == &crate::web_actions::native_web_review_response_marker_predicate()
690                || item.predicate().namespace()
691                    == super::assessment_defense::ASSESSMENT_DEFENSE_NAMESPACE;
692            !known_fact
693                || (item.predicate().namespace()
694                    == super::secret_exposure::SECRET_EXPOSURE_NAMESPACE
695                    && item.predicate() != &lineage_predicate)
696                || (item.predicate().namespace() == REST_REVIEW_EVIDENCE_NAMESPACE
697                    && !matches!(
698                        item.predicate().name(),
699                        "view"
700                            | "phase-terminal"
701                            | "status-equivalent"
702                            | "fields-equivalent"
703                            | "resources-equivalent"
704                    ))
705        }) {
706            return Err(RestRuntimeInvariantError::Catalog);
707        }
708        let view_predicate = KnowledgePredicate::new(REST_REVIEW_EVIDENCE_NAMESPACE, "view")
709            .map_err(|_| RestRuntimeInvariantError::Catalog)?;
710        let completion_predicate =
711            crate::web_actions::native_web_review_response_marker_predicate();
712        let completion = receipt
713            .evidence()
714            .iter()
715            .filter(|item| item.predicate() == &completion_predicate)
716            .collect::<Vec<_>>();
717        let classifications = receipt
718            .evidence()
719            .iter()
720            .filter(|item| {
721                item.predicate().namespace() == REST_REVIEW_EVIDENCE_NAMESPACE
722                    && item.predicate().name() != "phase-terminal"
723            })
724            .collect::<Vec<_>>();
725        let wire = standard
726            .transport()
727            .receipts()
728            .iter()
729            .filter(|item| item.action_id() == REST_REVIEW_ACTION_ID)
730            .nth(index)
731            .ok_or(RestRuntimeInvariantError::Catalog)?;
732        let bytes = rest_receipt_fact(
733            receipt,
734            &HttpEvidencePredicate::RESPONSE_BODY_BYTES_OBSERVED.into_knowledge(),
735        )?;
736        let Some(leg) = state.legs.get(&receipt.stage()) else {
737            if state.terminal.is_none()
738                || !classifications.is_empty()
739                || !completion.is_empty()
740                || phase.value() != &EvidenceValue::Boolean(true)
741                || !matches!(
742                    wire.outcome(),
743                    TransportDispatchOutcome::Completed
744                        | TransportDispatchOutcome::ResponseBudgetReached
745                )
746                || !matches!(bytes.value(), EvidenceValue::Unsigned(value) if *value <= wire.response_bytes())
747            {
748                return Err(RestRuntimeInvariantError::Catalog);
749            }
750            terminal_committed = true;
751            continue;
752        };
753        let mut expected_parents = BTreeSet::new();
754        for item in receipt
755            .evidence()
756            .iter()
757            .filter(|item| fixed_predicates.contains(item.predicate()) || item.id() == phase.id())
758        {
759            if !item.origin().is_direct() {
760                return Err(RestRuntimeInvariantError::Catalog);
761            }
762            expected_parents.insert(item.id().clone());
763        }
764        expected_parents.insert(lineage.id().clone());
765        let expected_parents = expected_parents.into_iter().collect::<Vec<_>>();
766        let view = rest_receipt_fact(receipt, &view_predicate)?;
767        let expected_view = EvidenceValue::TextList(vec![
768            format!("leg={}", rest_leg_identity(receipt.stage())),
769            format!("status={}", leg.view.status()),
770            "method=GET".to_owned(),
771            format!("media={}", observed_media_name(leg.media)),
772            "complete=true".to_owned(),
773            "truncated=false".to_owned(),
774            "rate_limited=false".to_owned(),
775            "challenged=false".to_owned(),
776        ]);
777        if completion.len() != 1
778            || !completion[0].origin().is_direct()
779            || completion[0].source().method() != "complete"
780            || completion[0].value() != &EvidenceValue::Boolean(true)
781            || phase.value() != &EvidenceValue::Boolean(false)
782            || bytes.value() != &EvidenceValue::Unsigned(leg.response_bytes)
783            || wire.outcome() != TransportDispatchOutcome::Completed
784            || wire.response_bytes() != leg.response_bytes
785            || rest_receipt_fact(
786                receipt,
787                &HttpEvidencePredicate::RESPONSE_BODY_TRUNCATED.into_knowledge(),
788            )?
789            .value()
790                != &EvidenceValue::Boolean(false)
791            || rest_receipt_fact(
792                receipt,
793                &HttpEvidencePredicate::RATE_LIMIT_DETECTED.into_knowledge(),
794            )?
795            .value()
796                != &EvidenceValue::Boolean(false)
797            || rest_receipt_fact(
798                receipt,
799                &HttpEvidencePredicate::RESPONSE_STATUS.into_knowledge(),
800            )?
801            .value()
802                != &EvidenceValue::Unsigned(u64::from(leg.view.status()))
803            || rest_receipt_fact(
804                receipt,
805                &HttpEvidencePredicate::RESPONSE_MEDIA_TYPE_JSON_COMPATIBLE.into_knowledge(),
806            )?
807            .value()
808                != &EvidenceValue::Boolean(true)
809            || view.value() != &expected_view
810            || view.source().method() != rest_leg_identity(receipt.stage())
811            || !valid_rest_classification(view, &expected_parents)
812        {
813            return Err(RestRuntimeInvariantError::Catalog);
814        }
815        let mut expected_ids = vec![view.id().clone()];
816        if receipt.stage() == DecisionExecutionStage::Active {
817            let candidate = state
818                .legs
819                .get(&DecisionExecutionStage::Passive)
820                .ok_or(RestRuntimeInvariantError::Catalog)?;
821            if !committed.contains(&DecisionExecutionStage::Passive) {
822                return Err(RestRuntimeInvariantError::Catalog);
823            }
824            let comparison = ApiVisibilityComparator::default()
825                .compare_exact_replay(&candidate.view, &leg.view)
826                .map_err(|_| RestRuntimeInvariantError::Catalog)?;
827            for (name, expected) in [
828                ("status-equivalent", comparison.status()),
829                ("fields-equivalent", comparison.fields()),
830                ("resources-equivalent", comparison.resources()),
831            ] {
832                let predicate = KnowledgePredicate::new(REST_REVIEW_EVIDENCE_NAMESPACE, name)
833                    .map_err(|_| RestRuntimeInvariantError::Catalog)?;
834                let item = rest_receipt_fact(receipt, &predicate)?;
835                if item.source().method() != name
836                    || item.value() != &EvidenceValue::Boolean(expected)
837                    || !valid_rest_classification(item, &expected_parents)
838                {
839                    return Err(RestRuntimeInvariantError::Catalog);
840                }
841                expected_ids.push(item.id().clone());
842            }
843        }
844        if classifications.len() != expected_ids.len() || leg.evidence_ids != expected_ids {
845            return Err(RestRuntimeInvariantError::Catalog);
846        }
847        committed.insert(receipt.stage());
848    }
849    Ok(state.legs.keys().all(|stage| committed.contains(stage))
850        && (state.terminal.is_none() || terminal_committed))
851}
852
853#[cfg(feature = "secret-exposure-review")]
854fn valid_rest_classification(item: &Evidence, parents: &[EvidenceId]) -> bool {
855    item.kind() == &EvidenceKind::Custom("rest-review".to_owned())
856        && item.origin().derivation().is_some_and(|derivation| {
857            derivation.algorithm().name() == REST_CLASSIFICATION_ALGORITHM
858                && derivation.algorithm().version() == 1
859                && derivation.parents() == parents
860        })
861}
862
863#[cfg(feature = "secret-exposure-review")]
864fn rest_receipt_fact<'a>(
865    receipt: &'a crate::DecisionEvidenceReceipt,
866    predicate: &KnowledgePredicate,
867) -> Result<&'a Evidence, RestRuntimeInvariantError> {
868    let mut matches = receipt
869        .evidence()
870        .iter()
871        .filter(|item| item.predicate() == predicate);
872    let item = matches.next().ok_or(RestRuntimeInvariantError::Catalog)?;
873    if matches.next().is_some() {
874        return Err(RestRuntimeInvariantError::Catalog);
875    }
876    Ok(item)
877}
878
879fn rest_transport_prefix_is_valid(receipts: &[&crate::TransportDispatchReceipt]) -> bool {
880    receipts.len() <= MAX_REST_REVIEW_REQUESTS
881        && receipts.iter().enumerate().all(|(index, receipt)| {
882            let expected_stage = if index == 0 {
883                DecisionExecutionStage::Passive
884            } else {
885                DecisionExecutionStage::Active
886            };
887            let expected_origin = (expected_stage == DecisionExecutionStage::Passive)
888                .then_some(DecisionActionOrigin::Planned);
889            receipt.stage() == expected_stage
890                && receipt.origin() == expected_origin
891                && receipt.request_body_bytes() == 0
892        })
893}
894
895fn captured_rest_prefix_reconciles(
896    state: &RestExecutionState,
897    receipts: &[&crate::TransportDispatchReceipt],
898) -> bool {
899    state.legs.iter().all(|(stage, leg)| {
900        let index = match stage {
901            DecisionExecutionStage::Passive => 0,
902            DecisionExecutionStage::Active => 1,
903        };
904        receipts.get(index).is_some_and(|receipt| {
905            receipt.outcome() == TransportDispatchOutcome::Completed
906                && receipt.response_bytes() == leg.response_bytes
907        })
908    })
909}
910
911#[derive(Debug, thiserror::Error)]
912pub(super) enum RestRuntimeInvariantError {
913    #[error("REST review catalog invariant failed")]
914    Catalog,
915}
916
917pub(super) enum RestRuntimeResult {
918    Complete(CommittedRestReview),
919    Stopped {
920        audit: WebAssessmentRestAudit,
921        runtime_limit: Option<RuntimeLimitExceeded>,
922    },
923}
924
925pub(super) struct CommittedRestReview {
926    subject: EntityId,
927    privacy: RestResponsePrivacy,
928    target_identity: Option<String>,
929    outcome: RestRuntimeOutcome,
930    evidence_ids: Vec<EvidenceId>,
931    audit: WebAssessmentRestAudit,
932}
933
934impl CommittedRestReview {
935    pub(super) const fn audit(&self) -> &WebAssessmentRestAudit {
936        &self.audit
937    }
938}
939
940struct RestLeg {
941    view: ApiVisibilityView,
942    response_bytes: u64,
943    media: RestObservedMediaClass,
944    evidence_ids: Vec<EvidenceId>,
945}
946
947impl RestLeg {
948    fn audit_observation(&self) -> RestAuditObservation {
949        RestAuditObservation {
950            media: self.media,
951            status_class: Some((self.view.status() / 100) as u8),
952        }
953    }
954}
955
956#[derive(Clone, Copy)]
957struct RestAuditObservation {
958    media: RestObservedMediaClass,
959    status_class: Option<u8>,
960}
961
962impl Default for RestAuditObservation {
963    fn default() -> Self {
964        Self {
965            media: RestObservedMediaClass::Unknown,
966            status_class: None,
967        }
968    }
969}
970
971impl RestAuditObservation {
972    fn new(status: u16, media: RestObservedMediaClass) -> Self {
973        Self {
974            media,
975            status_class: Some((status / 100) as u8),
976        }
977    }
978}
979
980#[derive(Default)]
981struct RestExecutionState {
982    legs: BTreeMap<DecisionExecutionStage, RestLeg>,
983    terminal: Option<RestRuntimeOutcome>,
984    terminal_observation: Option<RestAuditObservation>,
985    runtime_limit: Option<RuntimeLimitExceeded>,
986}
987
988struct RestDecisionExecutor {
989    requests: HttpRequestBroker,
990    selection: StableRestSelectionSlot,
991    subject: EntityId,
992    privacy: RestResponsePrivacy,
993    state: Mutex<RestExecutionState>,
994}
995
996impl RestDecisionExecutor {
997    fn take_state(&self) -> Result<RestExecutionState, RestRuntimeInvariantError> {
998        Ok(std::mem::take(
999            &mut *self
1000                .state
1001                .lock()
1002                .map_err(|_| RestRuntimeInvariantError::Catalog)?,
1003        ))
1004    }
1005
1006    fn stop(
1007        &self,
1008        outcome: RestRuntimeOutcome,
1009        runtime_limit: Option<RuntimeLimitExceeded>,
1010        observation: Option<RestAuditObservation>,
1011    ) -> Result<(), DecisionExecutorError> {
1012        let mut state = self
1013            .state
1014            .lock()
1015            .map_err(|_| DecisionExecutorError::new("REST review state is unavailable"))?;
1016        if state.terminal.is_some() || state.runtime_limit.is_some() {
1017            return Err(DecisionExecutorError::new(
1018                "REST review terminal state is duplicated",
1019            ));
1020        }
1021        state.terminal = Some(outcome);
1022        state.terminal_observation = observation;
1023        state.runtime_limit = runtime_limit;
1024        Ok(())
1025    }
1026}
1027
1028#[async_trait]
1029impl DecisionActionExecutor for RestDecisionExecutor {
1030    fn id(&self) -> &str {
1031        REST_REVIEW_EXECUTOR_ID
1032    }
1033
1034    async fn execute(
1035        &self,
1036        request: &DecisionExecutionRequest,
1037    ) -> Result<Vec<Evidence>, DecisionExecutorError> {
1038        if request.case().action_id() != REST_REVIEW_ACTION_ID
1039            || request.case().subject() != &self.subject
1040            || request.case().payload_strategy().is_some()
1041            || request.case().applies_hypothesis_transition()
1042            || request.delay_ms().is_some()
1043            || !matches!(
1044                request.stage(),
1045                DecisionExecutionStage::Passive | DecisionExecutionStage::Active
1046            )
1047        {
1048            return Err(DecisionExecutorError::new(
1049                "REST executor route contract failed",
1050            ));
1051        }
1052        {
1053            let state = self
1054                .state
1055                .lock()
1056                .map_err(|_| DecisionExecutorError::new("REST review state is unavailable"))?;
1057            let phase_ready = match request.stage() {
1058                DecisionExecutionStage::Passive => {
1059                    state.legs.is_empty() && state.terminal.is_none()
1060                },
1061                DecisionExecutionStage::Active => {
1062                    state.legs.contains_key(&DecisionExecutionStage::Passive)
1063                        && !state.legs.contains_key(&DecisionExecutionStage::Active)
1064                        && state.terminal.is_none()
1065                },
1066            };
1067            if !phase_ready {
1068                return Err(DecisionExecutorError::new(
1069                    "REST executor phase contract failed",
1070                ));
1071            }
1072        }
1073
1074        let Some(selection) = self
1075            .selection
1076            .selection()
1077            .map_err(|_| DecisionExecutorError::new("REST selection is unavailable"))?
1078        else {
1079            self.stop(RestRuntimeOutcome::NotEligible, None, None)?;
1080            return phase_terminal_evidence(request);
1081        };
1082        let probe = HttpProbe::new(selection.execution_url().clone(), HttpProbeMethod::Get)
1083            .and_then(|probe| probe.with_header("accept", REST_REVIEW_ACCEPT))
1084            .map_err(|_| DecisionExecutorError::new("REST request construction failed"))?;
1085        let response = match self
1086            .requests
1087            .collect_for_runtime(
1088                REST_REVIEW_ACTION_ID,
1089                request.stage(),
1090                request.origin(),
1091                request.limits(),
1092                &probe,
1093            )
1094            .await
1095        {
1096            Ok(response) => response,
1097            Err(HttpRequestBrokerError::RuntimeLimit(limit)) => {
1098                self.stop(RestRuntimeOutcome::BudgetExhausted, Some(limit), None)?;
1099                return phase_terminal_evidence(request);
1100            },
1101            Err(HttpRequestBrokerError::Http(_)) => {
1102                self.stop(RestRuntimeOutcome::Incomplete, None, None)?;
1103                return phase_terminal_evidence(request);
1104            },
1105        };
1106
1107        let status = response.status();
1108        let signal = response.openapi_defense_signal();
1109        let media = observed_media_class(response.normalized_media_type().as_deref());
1110        let terminal =
1111            if response.final_url() != selection.execution_url() || (300..400).contains(&status) {
1112                Some(RestRuntimeOutcome::Redirect)
1113            } else if response.body_truncated() {
1114                Some(RestRuntimeOutcome::Truncated)
1115            } else if !response.body_complete() {
1116                Some(RestRuntimeOutcome::Incomplete)
1117            } else if signal.state().is_rate_limited() || status == 429 {
1118                Some(RestRuntimeOutcome::RateLimited)
1119            } else if signal.state().is_challenged() {
1120                Some(RestRuntimeOutcome::DefensiveInterference)
1121            } else {
1122                status_outcome(status)
1123            };
1124        if let Some(outcome) = terminal {
1125            self.stop(
1126                outcome,
1127                None,
1128                Some(RestAuditObservation::new(status, media)),
1129            )?;
1130            return transport_evidence(request, &response, None, None, true, self.privacy);
1131        }
1132        if !response.has_json_compatible_media_type() {
1133            let outcome = match media {
1134                RestObservedMediaClass::Text => RestRuntimeOutcome::CompleteNonJson,
1135                RestObservedMediaClass::Unsupported | RestObservedMediaClass::Unknown => {
1136                    RestRuntimeOutcome::UnsupportedMedia
1137                },
1138                RestObservedMediaClass::JsonCompatible => unreachable!("media was checked above"),
1139            };
1140            self.stop(
1141                outcome,
1142                None,
1143                Some(RestAuditObservation::new(status, media)),
1144            )?;
1145            return transport_evidence(request, &response, None, None, true, self.privacy);
1146        }
1147        let json: serde_json::Value = match serde_json::from_slice(response.body()) {
1148            Ok(json) => json,
1149            Err(_) => {
1150                self.stop(
1151                    RestRuntimeOutcome::Incomplete,
1152                    None,
1153                    Some(RestAuditObservation::new(status, media)),
1154                )?;
1155                return transport_evidence(request, &response, None, None, true, self.privacy);
1156            },
1157        };
1158        let context_id = rest_leg_identity(request.stage());
1159        let view = match ApiVisibilityComparator::default().capture_view(
1160            context_id,
1161            selection.target_identity(),
1162            ApiSurfaceKind::JsonHttp,
1163            status,
1164            &json,
1165        ) {
1166            Ok(view) => view,
1167            Err(
1168                ApiVisibilityEvidenceError::DepthLimitExceeded { .. }
1169                | ApiVisibilityEvidenceError::NodeLimitExceeded { .. }
1170                | ApiVisibilityEvidenceError::FieldLimitExceeded { .. }
1171                | ApiVisibilityEvidenceError::CanonicalBytesLimitExceeded { .. },
1172            ) => {
1173                self.stop(
1174                    RestRuntimeOutcome::Incomplete,
1175                    None,
1176                    Some(RestAuditObservation::new(status, media)),
1177                )?;
1178                return transport_evidence(request, &response, None, None, true, self.privacy);
1179            },
1180            Err(_) => {
1181                return Err(DecisionExecutorError::new("REST JSON view capture failed"));
1182            },
1183        };
1184        drop(json);
1185
1186        let comparison = if request.stage() == DecisionExecutionStage::Active {
1187            let state = self
1188                .state
1189                .lock()
1190                .map_err(|_| DecisionExecutorError::new("REST review state is unavailable"))?;
1191            let candidate = state
1192                .legs
1193                .get(&DecisionExecutionStage::Passive)
1194                .ok_or_else(|| DecisionExecutorError::new("REST candidate view is missing"))?;
1195            Some(
1196                ApiVisibilityComparator::default()
1197                    .compare_exact_replay(&candidate.view, &view)
1198                    .map_err(|_| DecisionExecutorError::new("REST replay comparison failed"))?,
1199            )
1200        } else {
1201            None
1202        };
1203        let evidence = transport_evidence(
1204            request,
1205            &response,
1206            Some(&selection),
1207            comparison.as_ref(),
1208            false,
1209            self.privacy,
1210        )?;
1211        let evidence_ids = evidence
1212            .iter()
1213            .filter(|evidence| {
1214                evidence.predicate().namespace() == REST_REVIEW_EVIDENCE_NAMESPACE
1215                    && matches!(
1216                        evidence.predicate().name(),
1217                        "view" | "status-equivalent" | "fields-equivalent" | "resources-equivalent"
1218                    )
1219            })
1220            .map(|evidence| evidence.id().clone())
1221            .collect::<Vec<_>>();
1222        if evidence_ids.is_empty() {
1223            return Err(DecisionExecutorError::new("REST evidence is missing"));
1224        }
1225        let response_bytes = u64::try_from(response.body().len()).unwrap_or(u64::MAX);
1226        let replaced = self
1227            .state
1228            .lock()
1229            .map_err(|_| DecisionExecutorError::new("REST review state is unavailable"))?
1230            .legs
1231            .insert(
1232                request.stage(),
1233                RestLeg {
1234                    view,
1235                    response_bytes,
1236                    media,
1237                    evidence_ids,
1238                },
1239            );
1240        if replaced.is_some() {
1241            return Err(DecisionExecutorError::new("REST review leg is duplicated"));
1242        }
1243        Ok(evidence)
1244    }
1245}
1246
1247fn observed_media_class(media: Option<&str>) -> RestObservedMediaClass {
1248    match media {
1249        Some("application/json") => RestObservedMediaClass::JsonCompatible,
1250        Some(value) if value.ends_with("+json") => RestObservedMediaClass::JsonCompatible,
1251        Some(value) if value.starts_with("text/") => RestObservedMediaClass::Text,
1252        Some(_) => RestObservedMediaClass::Unsupported,
1253        None => RestObservedMediaClass::Unknown,
1254    }
1255}
1256
1257fn status_outcome(status: u16) -> Option<RestRuntimeOutcome> {
1258    match status {
1259        200..=299 => None,
1260        401 => Some(RestRuntimeOutcome::AuthenticationRequired),
1261        403 => Some(RestRuntimeOutcome::Forbidden),
1262        404 => Some(RestRuntimeOutcome::NotFound),
1263        429 => Some(RestRuntimeOutcome::RateLimited),
1264        500..=599 => Some(RestRuntimeOutcome::ServerError),
1265        _ => Some(RestRuntimeOutcome::Incomplete),
1266    }
1267}
1268
1269fn phase_terminal_evidence(
1270    request: &DecisionExecutionRequest,
1271) -> Result<Vec<Evidence>, DecisionExecutorError> {
1272    Ok(vec![
1273        make_evidence(
1274            request,
1275            HttpEvidencePredicate::RESPONSE_BODY_BYTES_OBSERVED.into_knowledge(),
1276            EvidenceValue::Unsigned(0),
1277            "response-body-size",
1278        )?,
1279        make_evidence(
1280            request,
1281            crate::web_actions::rest_review_phase_terminal_predicate(),
1282            EvidenceValue::Boolean(true),
1283            "phase-terminal",
1284        )?,
1285    ])
1286}
1287
1288fn make_evidence(
1289    request: &DecisionExecutionRequest,
1290    predicate: KnowledgePredicate,
1291    value: EvidenceValue,
1292    method: &str,
1293) -> Result<Evidence, DecisionExecutorError> {
1294    Ok(Evidence::new(
1295        request.case().subject().clone(),
1296        EvidenceKind::Custom("rest-review".into()),
1297        predicate,
1298        value,
1299        EvidenceSource::new(REST_REVIEW_EXECUTOR_ID, method)
1300            .and_then(|source| source.with_correlation_id(request.case().id()))
1301            .map_err(|_| DecisionExecutorError::new("REST evidence source failed"))?,
1302        ConfidenceScore::MAX,
1303    ))
1304}
1305
1306fn make_typed_evidence(
1307    request: &DecisionExecutionRequest,
1308    kind: EvidenceKind,
1309    predicate: KnowledgePredicate,
1310    value: EvidenceValue,
1311    method: &str,
1312) -> Result<Evidence, DecisionExecutorError> {
1313    Ok(Evidence::new(
1314        request.case().subject().clone(),
1315        kind,
1316        predicate,
1317        value,
1318        EvidenceSource::new(REST_REVIEW_EXECUTOR_ID, method)
1319            .and_then(|source| source.with_correlation_id(request.case().id()))
1320            .map_err(|_| DecisionExecutorError::new("REST evidence source failed"))?,
1321        ConfidenceScore::MAX,
1322    ))
1323}
1324
1325fn transport_evidence(
1326    request: &DecisionExecutionRequest,
1327    response: &crate::http_evidence::CollectedHttpResponse,
1328    selection: Option<&RestOperationSelection>,
1329    comparison: Option<&ApiExactReplayComparison>,
1330    terminal: bool,
1331    privacy: RestResponsePrivacy,
1332) -> Result<Vec<Evidence>, DecisionExecutorError> {
1333    let digest = (!privacy.is_value_free()).then(|| {
1334        let mut digest = Sha256::new();
1335        digest.update(REST_REVIEW_BODY_DIGEST_DOMAIN);
1336        digest.update(response.body());
1337        format!("{:x}", digest.finalize())
1338    });
1339    let signal = response.openapi_defense_signal();
1340    let mut evidence = vec![
1341        make_typed_evidence(
1342            request,
1343            EvidenceKind::Http,
1344            HttpEvidencePredicate::REQUEST_METHOD.into_knowledge(),
1345            EvidenceValue::Text("GET".into()),
1346            "request-method",
1347        )?,
1348        make_typed_evidence(
1349            request,
1350            EvidenceKind::Http,
1351            HttpEvidencePredicate::REQUEST_URL.into_knowledge(),
1352            EvidenceValue::Text(response.final_url().to_string()),
1353            "request-url",
1354        )?,
1355        make_typed_evidence(
1356            request,
1357            EvidenceKind::Http,
1358            HttpEvidencePredicate::RESPONSE_STATUS.into_knowledge(),
1359            EvidenceValue::Unsigned(u64::from(response.status())),
1360            "response-status",
1361        )?,
1362        make_typed_evidence(
1363            request,
1364            EvidenceKind::Http,
1365            HttpEvidencePredicate::RESPONSE_FINAL_URL.into_knowledge(),
1366            EvidenceValue::Text(response.final_url().to_string()),
1367            "response-final-url",
1368        )?,
1369        make_typed_evidence(
1370            request,
1371            EvidenceKind::Content,
1372            HttpEvidencePredicate::RESPONSE_BODY_BYTES_OBSERVED.into_knowledge(),
1373            EvidenceValue::Unsigned(u64::try_from(response.body().len()).unwrap_or(u64::MAX)),
1374            "response-body-size",
1375        )?,
1376        make_typed_evidence(
1377            request,
1378            EvidenceKind::Content,
1379            HttpEvidencePredicate::RESPONSE_BODY_TRUNCATED.into_knowledge(),
1380            EvidenceValue::Boolean(response.body_truncated()),
1381            "response-body-truncation",
1382        )?,
1383        make_typed_evidence(
1384            request,
1385            EvidenceKind::RateLimit,
1386            HttpEvidencePredicate::RATE_LIMIT_DETECTED.into_knowledge(),
1387            EvidenceValue::Boolean(response.status() == 429),
1388            "rate-limit-status",
1389        )?,
1390        make_typed_evidence(
1391            request,
1392            EvidenceKind::RateLimit,
1393            HttpEvidencePredicate::RATE_LIMIT_ADVERTISED.into_knowledge(),
1394            EvidenceValue::Boolean(signal.state().has_rate_limit_headers()),
1395            "rate-limit-headers",
1396        )?,
1397        make_evidence(
1398            request,
1399            crate::web_actions::rest_review_phase_terminal_predicate(),
1400            EvidenceValue::Boolean(terminal),
1401            "phase-terminal",
1402        )?,
1403    ];
1404    if let Some(digest) = digest.as_ref() {
1405        // Preserve historical evidence ordering and exact domain-separated hash.
1406        evidence.insert(
1407            6,
1408            make_typed_evidence(
1409                request,
1410                EvidenceKind::Content,
1411                HttpEvidencePredicate::RESPONSE_BODY_SHA256.into_knowledge(),
1412                EvidenceValue::Text(digest.clone()),
1413                "response-body-sha256",
1414            )?,
1415        );
1416    }
1417    if let Some(media) = response.normalized_media_type() {
1418        evidence.push(make_typed_evidence(
1419            request,
1420            EvidenceKind::Http,
1421            HttpEvidencePredicate::RESPONSE_MEDIA_TYPE.into_knowledge(),
1422            EvidenceValue::Text(media),
1423            "response-media-type",
1424        )?);
1425        evidence.push(make_typed_evidence(
1426            request,
1427            EvidenceKind::Http,
1428            HttpEvidencePredicate::RESPONSE_MEDIA_TYPE_JSON_COMPATIBLE.into_knowledge(),
1429            EvidenceValue::Boolean(response.has_json_compatible_media_type()),
1430            "response-media-type-json-compatibility",
1431        )?);
1432    }
1433    if privacy.is_value_free() {
1434        #[cfg(feature = "secret-exposure-review")]
1435        evidence.push(
1436            super::secret_exposure::project_rest_body_privacy_lineage(
1437                request,
1438                &evidence,
1439                REST_REVIEW_EXECUTOR_ID,
1440                ConfidenceScore::MAX,
1441            )
1442            .map_err(|_| DecisionExecutorError::new("REST privacy lineage failed"))?,
1443        );
1444        #[cfg(not(feature = "secret-exposure-review"))]
1445        return Err(DecisionExecutorError::new(
1446            "REST privacy selection is unavailable",
1447        ));
1448    }
1449    let fixed_predicates = [
1450        HttpEvidencePredicate::REQUEST_METHOD,
1451        HttpEvidencePredicate::REQUEST_URL,
1452        HttpEvidencePredicate::RESPONSE_STATUS,
1453        HttpEvidencePredicate::RESPONSE_FINAL_URL,
1454        HttpEvidencePredicate::RESPONSE_BODY_BYTES_OBSERVED,
1455        HttpEvidencePredicate::RESPONSE_BODY_TRUNCATED,
1456        HttpEvidencePredicate::RESPONSE_BODY_SHA256,
1457        HttpEvidencePredicate::RATE_LIMIT_DETECTED,
1458        HttpEvidencePredicate::RATE_LIMIT_ADVERTISED,
1459    ]
1460    .map(|predicate| predicate.into_knowledge());
1461    let defense_parents = evidence
1462        .iter()
1463        .filter(|item| {
1464            #[cfg(not(feature = "secret-exposure-review"))]
1465            let privacy_parent = false;
1466            #[cfg(feature = "secret-exposure-review")]
1467            let privacy_parent = privacy.is_value_free()
1468                && item.predicate().namespace()
1469                    == super::secret_exposure::SECRET_EXPOSURE_NAMESPACE
1470                && item.predicate().name() == super::secret_exposure::ACTIVE_REVIEW_BODY_LINEAGE;
1471            fixed_predicates.contains(item.predicate()) || privacy_parent
1472        })
1473        .map(|item| item.id().clone())
1474        .collect();
1475    let classification_parents = evidence
1476        .iter()
1477        .map(|item| item.id().clone())
1478        .collect::<Vec<_>>();
1479    if let Some(selection) = selection {
1480        let leg_identity = rest_leg_identity(request.stage());
1481        let mut values = vec![
1482            format!("leg={leg_identity}"),
1483            format!("status={}", response.status()),
1484            "method=GET".into(),
1485            format!(
1486                "media={}",
1487                observed_media_name(observed_media_class(
1488                    response.normalized_media_type().as_deref()
1489                ))
1490            ),
1491            format!("complete={}", response.body_complete()),
1492            format!("truncated={}", response.body_truncated()),
1493            format!("rate_limited={}", signal.state().is_rate_limited()),
1494            format!("challenged={}", signal.state().is_challenged()),
1495        ];
1496        if let Some(digest) = digest.as_ref() {
1497            values.insert(
1498                1,
1499                format!("operation={}", selection.operation_id().as_str()),
1500            );
1501            values.insert(2, format!("target={}", selection.target_identity()));
1502            values.insert(8, format!("response={digest}"));
1503        }
1504        let item = make_evidence(
1505            request,
1506            KnowledgePredicate::new(REST_REVIEW_EVIDENCE_NAMESPACE, "view")
1507                .map_err(|_| DecisionExecutorError::new("REST predicate failed"))?,
1508            EvidenceValue::TextList(values),
1509            leg_identity,
1510        )?;
1511        evidence.push(classification_evidence(
1512            item,
1513            &classification_parents,
1514            privacy,
1515        )?);
1516    }
1517    if let Some(comparison) = comparison {
1518        for (name, equivalent) in [
1519            ("status-equivalent", comparison.status()),
1520            ("fields-equivalent", comparison.fields()),
1521            ("resources-equivalent", comparison.resources()),
1522        ] {
1523            let item = make_evidence(
1524                request,
1525                KnowledgePredicate::new(REST_REVIEW_EVIDENCE_NAMESPACE, name)
1526                    .map_err(|_| DecisionExecutorError::new("REST predicate failed"))?,
1527                EvidenceValue::Boolean(equivalent),
1528                name,
1529            )?;
1530            evidence.push(classification_evidence(
1531                item,
1532                &classification_parents,
1533                privacy,
1534            )?);
1535        }
1536    }
1537    if !terminal {
1538        evidence.push(make_evidence(
1539            request,
1540            crate::web_actions::native_web_review_response_marker_predicate(),
1541            EvidenceValue::Boolean(true),
1542            "complete",
1543        )?);
1544    }
1545    evidence.extend(
1546        project_assessment_defense_signal(
1547            &signal,
1548            AssessmentDefenseProjectionContext {
1549                subject: request.case().subject(),
1550                case_id: request.case().id(),
1551                executor_id: REST_REVIEW_EXECUTOR_ID,
1552                reliability: ConfidenceScore::MAX,
1553                parents: defense_parents,
1554            },
1555        )
1556        .map_err(|_| DecisionExecutorError::new("REST defense projection failed"))?,
1557    );
1558    Ok(evidence)
1559}
1560
1561fn classification_evidence(
1562    item: Evidence,
1563    parents: &[EvidenceId],
1564    privacy: RestResponsePrivacy,
1565) -> Result<Evidence, DecisionExecutorError> {
1566    if !privacy.is_value_free() {
1567        return Ok(item);
1568    }
1569    let derivation = termivar_core::EvidenceDerivation::new(
1570        parents.to_vec(),
1571        termivar_core::DerivationAlgorithm::new(REST_CLASSIFICATION_ALGORITHM, 1)
1572            .map_err(|_| DecisionExecutorError::new("REST classification algorithm failed"))?,
1573    )
1574    .map_err(|_| DecisionExecutorError::new("REST classification provenance failed"))?;
1575    Ok(item.derived_from(derivation))
1576}
1577
1578fn rest_leg_identity(stage: DecisionExecutionStage) -> &'static str {
1579    match stage {
1580        DecisionExecutionStage::Passive => "rest-review:candidate",
1581        DecisionExecutionStage::Active => "rest-review:replay",
1582    }
1583}
1584
1585fn observed_media_name(media: RestObservedMediaClass) -> &'static str {
1586    match media {
1587        RestObservedMediaClass::JsonCompatible => "json-compatible",
1588        RestObservedMediaClass::Text => "text",
1589        RestObservedMediaClass::Unsupported => "unsupported",
1590        RestObservedMediaClass::Unknown => "unknown",
1591    }
1592}
1593
1594fn audit(
1595    selection: Option<&RestOperationSelection>,
1596    outcome: RestRuntimeOutcome,
1597    requests: u8,
1598    observation: Option<RestAuditObservation>,
1599    projected: bool,
1600) -> WebAssessmentRestAudit {
1601    WebAssessmentRestAudit {
1602        outcome,
1603        request_count: requests,
1604        active_verification_count: u8::from(requests == MAX_REST_REVIEW_REQUESTS as u8),
1605        eligible_operation_count: selection
1606            .map_or(0, RestOperationSelection::eligible_operation_count),
1607        selected_operation_identity: selection
1608            .map(|selected| selected.operation_id().as_str().to_owned()),
1609        documented_response: selection.map(RestOperationSelection::documented_response),
1610        observed_media: observation.unwrap_or_default().media,
1611        status_class: observation.and_then(|observation| observation.status_class),
1612        replay_stable: projected,
1613        item_projected: projected,
1614    }
1615}
1616
1617fn audit_for_privacy(
1618    selection: Option<&RestOperationSelection>,
1619    outcome: RestRuntimeOutcome,
1620    requests: u8,
1621    observation: Option<RestAuditObservation>,
1622    projected: bool,
1623    privacy: RestResponsePrivacy,
1624) -> WebAssessmentRestAudit {
1625    let mut audit = audit(selection, outcome, requests, observation, projected);
1626    if privacy.is_value_free() {
1627        audit.selected_operation_identity = None;
1628    }
1629    audit
1630}
1631
1632pub(super) fn project_rest_item(
1633    context: &mut AssessmentProjectionContext,
1634    knowledge: &KnowledgeBase,
1635    review: &CommittedRestReview,
1636) -> Result<(), AssessmentItemProjectionError> {
1637    for evidence_id in &review.evidence_ids {
1638        context.register_evidence(knowledge, evidence_id)?;
1639    }
1640    if review.outcome == RestRuntimeOutcome::SurfaceObserved {
1641        let target = if review.privacy.is_value_free() {
1642            AssessmentItemTarget::Subject
1643        } else {
1644            let target_identity = review
1645                .target_identity
1646                .clone()
1647                .ok_or(AssessmentItemProjectionError::InvalidStableSubjectIdentity)?;
1648            AssessmentItemTarget::rest_operation(target_identity)?
1649        };
1650        context.project_observation(
1651            &REST_REVIEW_CAPABILITY,
1652            knowledge,
1653            &review.subject,
1654            &target,
1655            &review.evidence_ids,
1656        )?;
1657    }
1658    Ok(())
1659}
1660
1661#[cfg(test)]
1662mod tests {
1663    use super::*;
1664    use crate::{
1665        openapi_review::{parse_openapi_document, OpenApiParseOutcome},
1666        rest_review::{select_rest_operation, RestOperationSelectionOutcome},
1667    };
1668    use url::Url;
1669
1670    fn selection() -> RestOperationSelection {
1671        let origin = Url::parse("https://example.test/openapi.json").unwrap();
1672        let document = br#"{
1673          "openapi":"3.1.0",
1674          "paths":{"/health":{"get":{"responses":{"200":{"content":{"application/json":{}}}}}}}
1675        }"#;
1676        let OpenApiParseOutcome::Complete(document) = parse_openapi_document(document, &origin)
1677        else {
1678            panic!("fixture must parse");
1679        };
1680        let RestOperationSelectionOutcome::Selected(selection) =
1681            select_rest_operation(&document, &origin)
1682        else {
1683            panic!("fixture must select one operation");
1684        };
1685        selection
1686    }
1687
1688    #[test]
1689    fn stable_selection_slot_is_clone_shared_single_assignment_and_redacted() {
1690        let slot = StableRestSelectionSlot::new();
1691        let clone = slot.clone();
1692        let selected = selection();
1693        let secret_url = selected.execution_url().to_string();
1694        slot.commit(selected.clone()).unwrap();
1695        assert_eq!(clone.selection().unwrap(), Some(selected.clone()));
1696        assert!(matches!(
1697            clone.commit(selected),
1698            Err(RestRuntimeInvariantError::Catalog)
1699        ));
1700        let debug = format!("{slot:?}");
1701        assert!(!debug.contains(&secret_url));
1702        assert!(!debug.contains("/health"));
1703    }
1704
1705    #[test]
1706    fn status_and_media_classification_are_closed_and_conservative() {
1707        assert_eq!(status_outcome(200), None);
1708        assert_eq!(
1709            status_outcome(401),
1710            Some(RestRuntimeOutcome::AuthenticationRequired)
1711        );
1712        assert_eq!(status_outcome(403), Some(RestRuntimeOutcome::Forbidden));
1713        assert_eq!(status_outcome(404), Some(RestRuntimeOutcome::NotFound));
1714        assert_eq!(status_outcome(429), Some(RestRuntimeOutcome::RateLimited));
1715        assert_eq!(status_outcome(503), Some(RestRuntimeOutcome::ServerError));
1716        assert_eq!(status_outcome(418), Some(RestRuntimeOutcome::Incomplete));
1717        assert_eq!(
1718            observed_media_class(Some("application/problem+json")),
1719            RestObservedMediaClass::JsonCompatible
1720        );
1721        assert_eq!(
1722            observed_media_class(Some("text/html")),
1723            RestObservedMediaClass::Text
1724        );
1725        assert_eq!(
1726            observed_media_class(Some("application/octet-stream")),
1727            RestObservedMediaClass::Unsupported
1728        );
1729        assert_eq!(observed_media_class(None), RestObservedMediaClass::Unknown);
1730        assert_eq!(
1731            rest_leg_identity(DecisionExecutionStage::Passive),
1732            "rest-review:candidate"
1733        );
1734        assert_eq!(
1735            rest_leg_identity(DecisionExecutionStage::Active),
1736            "rest-review:replay"
1737        );
1738        assert_ne!(
1739            rest_leg_identity(DecisionExecutionStage::Passive),
1740            rest_leg_identity(DecisionExecutionStage::Active)
1741        );
1742    }
1743
1744    #[test]
1745    fn audit_debug_never_exposes_operation_identity() {
1746        let selected = selection();
1747        let audit = audit(
1748            Some(&selected),
1749            RestRuntimeOutcome::SurfaceObserved,
1750            2,
1751            None,
1752            true,
1753        );
1754        let debug = format!("{audit:?}");
1755        assert!(!debug.contains(selected.operation_id().as_str()));
1756        assert!(!debug.contains(selected.execution_url().path()));
1757        assert_eq!(audit.request_count(), 2);
1758        assert_eq!(audit.active_verification_count(), 1);
1759        assert!(audit.replay_stable() && audit.item_projected());
1760    }
1761
1762    // PRIVATE integration snippet: insert inside rest_runtime.rs's final cfg(test)
1763    // module. Original synthetic committed-receipt oracles, NOT captured HTTP.
1764    // This file has not been compiled or executed by its author.
1765    #[cfg(feature = "secret-exposure-review")]
1766    mod privacy_tests {
1767        use super::*;
1768        use crate::{
1769            runtime_budget::RequestAccountingBroker, ActionCost, AdaptationLimits, AttackAction,
1770            BenefitScore, DecisionEvidenceReceipt, DecisionLoop, DecisionLoopCommand,
1771            DecisionLoopConfig, DecisionOutcomeReport, DecisionRunnerAdapter, DecisionSession,
1772            DecisionStopReason, ExperiencePolicy, ExperienceStore, Expression, HypothesisSelector,
1773            KnowledgeLayer, KnowledgeWrite, PlanningContext, RequiredStrength, RiskScore,
1774            RuntimeBudget, RuntimeUsage, StandardWebDecisionRunReport,
1775            StandardWebDecisionRuntimeTurn, VerificationCase, VerificationTarget,
1776        };
1777        use std::collections::BTreeSet;
1778        use termivar_core::{DerivationAlgorithm, EvidenceDerivation, Probability};
1779
1780        const CASE: &str = "case:synthetic-rest-privacy";
1781        const RESOURCE: &str = "https://example.test/health";
1782
1783        struct LiteralExecutor {
1784            id: &'static str,
1785            batch: Vec<Evidence>,
1786        }
1787
1788        #[async_trait]
1789        impl DecisionActionExecutor for LiteralExecutor {
1790            fn id(&self) -> &str {
1791                self.id
1792            }
1793
1794            async fn execute(
1795                &self,
1796                _request: &DecisionExecutionRequest,
1797            ) -> Result<Vec<Evidence>, DecisionExecutorError> {
1798                assert!(self.batch.len() <= 32);
1799                Ok(self.batch.clone())
1800            }
1801        }
1802
1803        fn subject() -> EntityId {
1804            EntityId::new("endpoint:https://example.test/").unwrap()
1805        }
1806
1807        fn fact(
1808            kind: EvidenceKind,
1809            predicate: KnowledgePredicate,
1810            value: EvidenceValue,
1811            method: &str,
1812        ) -> Evidence {
1813            Evidence::new(
1814                subject(),
1815                kind,
1816                predicate,
1817                value,
1818                EvidenceSource::new("http.rest-review", method)
1819                    .unwrap()
1820                    .with_correlation_id(CASE)
1821                    .unwrap(),
1822                ConfidenceScore::MAX,
1823            )
1824        }
1825
1826        fn custom(name: &str, value: EvidenceValue, method: &str) -> Evidence {
1827            fact(
1828                EvidenceKind::Custom("rest-review".to_owned()),
1829                KnowledgePredicate::new("web.rest-review.transport", name).unwrap(),
1830                value,
1831                method,
1832            )
1833        }
1834
1835        fn no_response_batch() -> Vec<Evidence> {
1836            vec![
1837                fact(
1838                    EvidenceKind::Custom("rest-review".to_owned()),
1839                    HttpEvidencePredicate::RESPONSE_BODY_BYTES_OBSERVED.into_knowledge(),
1840                    EvidenceValue::Unsigned(0),
1841                    "response-body-size",
1842                ),
1843                fact(
1844                    EvidenceKind::Custom("rest-review".to_owned()),
1845                    crate::web_actions::rest_review_phase_terminal_predicate(),
1846                    EvidenceValue::Boolean(true),
1847                    "phase-terminal",
1848                ),
1849            ]
1850        }
1851
1852        fn received_batch(parsed: bool, stage: DecisionExecutionStage) -> Vec<Evidence> {
1853            // These literal values describe one original eight-byte JSON representation.
1854            // No producer/proof helper supplies the expected view or classification.
1855            let rows = [
1856                (
1857                    HttpEvidencePredicate::REQUEST_METHOD,
1858                    EvidenceKind::Http,
1859                    EvidenceValue::Text("GET".to_owned()),
1860                    "request-method",
1861                ),
1862                (
1863                    HttpEvidencePredicate::REQUEST_URL,
1864                    EvidenceKind::Http,
1865                    EvidenceValue::Text(RESOURCE.to_owned()),
1866                    "request-url",
1867                ),
1868                (
1869                    HttpEvidencePredicate::RESPONSE_STATUS,
1870                    EvidenceKind::Http,
1871                    EvidenceValue::Unsigned(if parsed { 200 } else { 404 }),
1872                    "response-status",
1873                ),
1874                (
1875                    HttpEvidencePredicate::RESPONSE_FINAL_URL,
1876                    EvidenceKind::Http,
1877                    EvidenceValue::Text(RESOURCE.to_owned()),
1878                    "response-final-url",
1879                ),
1880                (
1881                    HttpEvidencePredicate::RESPONSE_BODY_BYTES_OBSERVED,
1882                    EvidenceKind::Content,
1883                    EvidenceValue::Unsigned(8),
1884                    "response-body-size",
1885                ),
1886                (
1887                    HttpEvidencePredicate::RESPONSE_BODY_TRUNCATED,
1888                    EvidenceKind::Content,
1889                    EvidenceValue::Boolean(false),
1890                    "response-body-truncation",
1891                ),
1892                (
1893                    HttpEvidencePredicate::RATE_LIMIT_DETECTED,
1894                    EvidenceKind::RateLimit,
1895                    EvidenceValue::Boolean(false),
1896                    "rate-limit-status",
1897                ),
1898                (
1899                    HttpEvidencePredicate::RATE_LIMIT_ADVERTISED,
1900                    EvidenceKind::RateLimit,
1901                    EvidenceValue::Boolean(false),
1902                    "rate-limit-headers",
1903                ),
1904            ];
1905            let mut batch = rows
1906                .into_iter()
1907                .map(|(predicate, kind, value, method)| {
1908                    fact(kind, predicate.into_knowledge(), value, method)
1909                })
1910                .collect::<Vec<_>>();
1911            batch.push(fact(
1912                EvidenceKind::Custom("rest-review".to_owned()),
1913                crate::web_actions::rest_review_phase_terminal_predicate(),
1914                EvidenceValue::Boolean(!parsed),
1915                "phase-terminal",
1916            ));
1917            batch.push(fact(
1918                EvidenceKind::Http,
1919                HttpEvidencePredicate::RESPONSE_MEDIA_TYPE.into_knowledge(),
1920                EvidenceValue::Text("application/json".to_owned()),
1921                "response-media-type",
1922            ));
1923            batch.push(fact(
1924                EvidenceKind::Http,
1925                HttpEvidencePredicate::RESPONSE_MEDIA_TYPE_JSON_COMPATIBLE.into_knowledge(),
1926                EvidenceValue::Boolean(true),
1927                "response-media-type-json-compatibility",
1928            ));
1929            // Six closed HTTP transport roots and the optional media root, independently
1930            // specified here. A compatibility assertion is NOT a byte-lineage root.
1931            let parents: Vec<_> = [0, 1, 2, 3, 4, 5, 9]
1932                .into_iter()
1933                .map(|index| batch[index].id().clone())
1934                .collect();
1935            batch.push(
1936                fact(
1937                    EvidenceKind::Custom("passive-secret-exposure".to_owned()),
1938                    KnowledgePredicate::new("web.secret-exposure", "active_review_body_lineage")
1939                        .unwrap(),
1940                    EvidenceValue::Boolean(true),
1941                    "active-review-body-digest-privacy-guard",
1942                )
1943                .derived_from(
1944                    EvidenceDerivation::new(
1945                        parents,
1946                        DerivationAlgorithm::new(
1947                            "web.secret-exposure.active-review-body-digest-privacy-guard",
1948                            1,
1949                        )
1950                        .unwrap(),
1951                    )
1952                    .unwrap(),
1953                ),
1954            );
1955            if parsed {
1956                let parents = batch
1957                    .iter()
1958                    .map(|item| item.id().clone())
1959                    .collect::<Vec<_>>();
1960                let (identity, view) = match stage {
1961                    DecisionExecutionStage::Passive => (
1962                        "rest-review:candidate",
1963                        vec![
1964                            "leg=rest-review:candidate",
1965                            "status=200",
1966                            "method=GET",
1967                            "media=json-compatible",
1968                            "complete=true",
1969                            "truncated=false",
1970                            "rate_limited=false",
1971                            "challenged=false",
1972                        ],
1973                    ),
1974                    DecisionExecutionStage::Active => (
1975                        "rest-review:replay",
1976                        vec![
1977                            "leg=rest-review:replay",
1978                            "status=200",
1979                            "method=GET",
1980                            "media=json-compatible",
1981                            "complete=true",
1982                            "truncated=false",
1983                            "rate_limited=false",
1984                            "challenged=false",
1985                        ],
1986                    ),
1987                };
1988                batch.push(
1989                    custom(
1990                        "view",
1991                        EvidenceValue::TextList(view.into_iter().map(str::to_owned).collect()),
1992                        identity,
1993                    )
1994                    .derived_from(
1995                        EvidenceDerivation::new(
1996                            parents.clone(),
1997                            DerivationAlgorithm::new(
1998                                "web.rest-review.value-free-classification",
1999                                1,
2000                            )
2001                            .unwrap(),
2002                        )
2003                        .unwrap(),
2004                    ),
2005                );
2006                if stage == DecisionExecutionStage::Active {
2007                    // Independent stable-replay oracle: the same literal JSON and status
2008                    // on both legs imply all three true; no production compare is called.
2009                    for name in [
2010                        "status-equivalent",
2011                        "fields-equivalent",
2012                        "resources-equivalent",
2013                    ] {
2014                        batch.push(
2015                            custom(name, EvidenceValue::Boolean(true), name).derived_from(
2016                                EvidenceDerivation::new(
2017                                    parents.clone(),
2018                                    DerivationAlgorithm::new(
2019                                        "web.rest-review.value-free-classification",
2020                                        1,
2021                                    )
2022                                    .unwrap(),
2023                                )
2024                                .unwrap(),
2025                            ),
2026                        );
2027                    }
2028                }
2029                batch.push(fact(
2030                    EvidenceKind::Custom("rest-review".to_owned()),
2031                    crate::web_actions::native_web_review_response_marker_predicate(),
2032                    EvidenceValue::Boolean(true),
2033                    "complete",
2034                ));
2035            }
2036            batch
2037        }
2038
2039        fn replace(
2040            item: &Evidence,
2041            kind: EvidenceKind,
2042            value: EvidenceValue,
2043            source: EvidenceSource,
2044            parents: Option<Vec<EvidenceId>>,
2045        ) -> Evidence {
2046            let mut result = Evidence::with_id_at(
2047                item.id().clone(),
2048                item.subject().clone(),
2049                kind,
2050                item.predicate().clone(),
2051                value,
2052                source,
2053                item.reliability(),
2054                item.observed_at_ms(),
2055            );
2056            if let Some(original) = item.origin().derivation() {
2057                result = result.derived_from(
2058                    EvidenceDerivation::new(
2059                        parents.unwrap_or_else(|| original.parents().to_vec()),
2060                        original.algorithm().clone(),
2061                    )
2062                    .unwrap(),
2063                );
2064            }
2065            result
2066        }
2067
2068        async fn commit_route(
2069            knowledge: &KnowledgeBase,
2070            batch: Vec<Evidence>,
2071            action: &str,
2072            executor: &'static str,
2073            stage: DecisionExecutionStage,
2074        ) -> DecisionEvidenceReceipt {
2075            // The ordinary runner commits even alternate-route fixtures; rejection by
2076            // the narrower REST gate is not merely a failed executor setup.
2077            let batch = if executor == "http.rest-review" {
2078                batch
2079            } else {
2080                batch
2081                    .into_iter()
2082                    .map(|item| {
2083                        let source = EvidenceSource::new(executor, item.source().method())
2084                            .unwrap()
2085                            .with_correlation_id(CASE)
2086                            .unwrap();
2087                        replace(
2088                            &item,
2089                            item.kind().clone(),
2090                            item.value().clone(),
2091                            source,
2092                            None,
2093                        )
2094                    })
2095                    .collect()
2096            };
2097            let mut registry = DecisionExecutorRegistry::new();
2098            registry
2099                .register(Arc::new(LiteralExecutor {
2100                    id: executor,
2101                    batch,
2102                }))
2103                .unwrap();
2104            registry.route_action(stage, action, executor).unwrap();
2105            let case = VerificationCase::new(CASE, subject(), action, "hypothesis:synthetic-rest")
2106                .unwrap()
2107                .without_hypothesis_transition();
2108            let command = match stage {
2109                DecisionExecutionStage::Passive => DecisionLoopCommand::ExecuteAction {
2110                    case,
2111                    executor: None,
2112                    origin: DecisionActionOrigin::Planned,
2113                    delay_ms: None,
2114                },
2115                DecisionExecutionStage::Active => {
2116                    DecisionLoopCommand::CollectActiveEvidence { case }
2117                },
2118            };
2119            DecisionRunnerAdapter::new(registry)
2120                .execute_command(&command, knowledge)
2121                .await
2122                .unwrap()
2123        }
2124
2125        async fn committed(batch: Vec<Evidence>) -> (KnowledgeBase, DecisionEvidenceReceipt) {
2126            let knowledge = KnowledgeBase::new();
2127            let receipt = commit_route(
2128                &knowledge,
2129                batch,
2130                "web.review.rest.readonly-replay@1",
2131                "http.rest-review",
2132                DecisionExecutionStage::Passive,
2133            )
2134            .await;
2135            (knowledge, receipt)
2136        }
2137
2138        fn report(
2139            receipt: Option<DecisionEvidenceReceipt>,
2140            received: bool,
2141        ) -> StandardWebDecisionRunReport {
2142            let accounting = RequestAccountingBroker::new(RuntimeBudget::default());
2143            if received {
2144                let mut lease = accounting
2145                    .try_begin(
2146                        "web.review.rest.readonly-replay@1",
2147                        DecisionExecutionStage::Passive,
2148                        Some(DecisionActionOrigin::Planned),
2149                    )
2150                    .unwrap();
2151                assert_eq!(lease.observe_response_bytes(8), 8);
2152                lease.finish(TransportDispatchOutcome::Completed);
2153            }
2154            StandardWebDecisionRunReport {
2155                bootstrap: None,
2156                turns: Vec::new(),
2157                unverified_evidence: receipt,
2158                terminal: DecisionLoopCommand::Halt {
2159                    reason: DecisionStopReason::NoEligibleAction,
2160                },
2161                usage: RuntimeUsage::default(),
2162                transport: accounting.dispatch_audit(),
2163                limit_exceeded: None,
2164                execution_failure: None,
2165            }
2166        }
2167
2168        fn terminal_state() -> RestExecutionState {
2169            RestExecutionState {
2170                legs: BTreeMap::new(),
2171                terminal: Some(RestRuntimeOutcome::NotFound),
2172                terminal_observation: Some(RestAuditObservation::new(
2173                    404,
2174                    RestObservedMediaClass::JsonCompatible,
2175                )),
2176                runtime_limit: None,
2177            }
2178        }
2179
2180        fn parsed_leg(batch: &[Evidence], stage: DecisionExecutionStage) -> RestLeg {
2181            let identity = match stage {
2182                DecisionExecutionStage::Passive => "rest-review:candidate",
2183                DecisionExecutionStage::Active => "rest-review:replay",
2184            };
2185            let names: &[&str] = if stage == DecisionExecutionStage::Passive {
2186                &["view"]
2187            } else {
2188                &[
2189                    "view",
2190                    "status-equivalent",
2191                    "fields-equivalent",
2192                    "resources-equivalent",
2193                ]
2194            };
2195            RestLeg {
2196                // capture_view stages private state only. It is not the expected report,
2197                // lineage, or comparison oracle and does not perform transport.
2198                view: ApiVisibilityComparator::default()
2199                    .capture_view(
2200                        identity,
2201                        super::selection().target_identity(),
2202                        ApiSurfaceKind::JsonHttp,
2203                        200,
2204                        &serde_json::json!({"ok": 1}),
2205                    )
2206                    .unwrap(),
2207                response_bytes: 8,
2208                media: RestObservedMediaClass::JsonCompatible,
2209                evidence_ids: names
2210                    .iter()
2211                    .map(|name| {
2212                        batch
2213                            .iter()
2214                            .find(|item| {
2215                                item.predicate().namespace() == "web.rest-review.transport"
2216                                    && item.predicate().name() == *name
2217                            })
2218                            .unwrap()
2219                            .id()
2220                            .clone()
2221                    })
2222                    .collect(),
2223            }
2224        }
2225
2226        fn parsed_state(batch: &[Evidence]) -> RestExecutionState {
2227            RestExecutionState {
2228                legs: BTreeMap::from([(
2229                    DecisionExecutionStage::Passive,
2230                    parsed_leg(batch, DecisionExecutionStage::Passive),
2231                )]),
2232                ..RestExecutionState::default()
2233            }
2234        }
2235
2236        fn validate(
2237            receipt: Option<DecisionEvidenceReceipt>,
2238            knowledge: &KnowledgeBase,
2239            state: &RestExecutionState,
2240            received: bool,
2241        ) -> Result<bool, RestRuntimeInvariantError> {
2242            validate_value_free_rest_commits(
2243                &report(receipt, received),
2244                knowledge,
2245                &subject(),
2246                Some(&super::selection()),
2247                state,
2248            )
2249        }
2250
2251        fn mutation(
2252            template: &DecisionEvidenceReceipt,
2253            batch: Vec<Evidence>,
2254        ) -> (KnowledgeBase, DecisionEvidenceReceipt) {
2255            let knowledge = KnowledgeBase::new();
2256            let writes = knowledge.insert_evidence_batch(batch.clone()).unwrap();
2257            let receipt = template.with_test_committed_batch(
2258                batch,
2259                writes,
2260                knowledge.snapshot_for_subject(&subject()),
2261            );
2262            (knowledge, receipt)
2263        }
2264
2265        // A real decision result is only a holder for the report's committed-turn
2266        // shape. Neither its result nor its planner manufactures the REST oracle.
2267        fn decision_holder(
2268            knowledge: &KnowledgeBase,
2269            receipt: &DecisionEvidenceReceipt,
2270        ) -> DecisionOutcomeReport {
2271            let planning = PlanningContext::new(
2272                BenefitScore::from_percent(90).unwrap(),
2273                100,
2274                RiskScore::from_percent(50).unwrap(),
2275            );
2276            let config = DecisionLoopConfig::new(
2277                planning,
2278                AdaptationLimits::default(),
2279                ExperiencePolicy::default(),
2280                1,
2281            )
2282            .unwrap();
2283            let mut decision = DecisionLoop::new(config);
2284            let predicate = KnowledgePredicate::new("synthetic", "rest-holder").unwrap();
2285            let holder_hypothesis = termivar_core::Hypothesis::with_id(
2286                "hypothesis:synthetic-rest",
2287                subject(),
2288                predicate.clone(),
2289                EvidenceValue::Boolean(true),
2290                Probability::from_percent(50).unwrap(),
2291            )
2292            .unwrap();
2293            knowledge.upsert_hypothesis(holder_hypothesis).unwrap();
2294            let action = AttackAction::new(
2295                "web.review.rest.readonly-replay@1",
2296                "http.rest-review",
2297                Expression::equals(
2298                    KnowledgeLayer::Hypothesis,
2299                    predicate.clone(),
2300                    EvidenceValue::Boolean(true),
2301                ),
2302                HypothesisSelector::new(
2303                    predicate,
2304                    EvidenceValue::Boolean(true),
2305                    Probability::from_percent(50).unwrap(),
2306                    RequiredStrength::Strong,
2307                ),
2308                BenefitScore::from_percent(80).unwrap(),
2309                ActionCost::new(10).unwrap(),
2310                RiskScore::from_percent(10).unwrap(),
2311                BTreeSet::new(),
2312            )
2313            .unwrap()
2314            .with_verification_target(VerificationTarget::KnowledgeOnly);
2315            decision.planner_mut().register(action).unwrap();
2316            let mut wire = serde_json::to_value(DecisionSession::new(subject())).unwrap();
2317            wire["action_cycles"] = serde_json::json!(1);
2318            wire["state"] =
2319                serde_json::json!({"state": "awaiting_passive", "case": receipt.case()});
2320            let mut session: DecisionSession = serde_json::from_value(wire).unwrap();
2321            // Explicit host refusal prevents any further automated work for
2322            // this holder-only outcome; it is not a REST evidence oracle.
2323            let suppressions = crate::planner::ActionSuppressionContext::new(
2324                BTreeSet::new(),
2325                BTreeSet::from(["web.review.rest.readonly-replay@1".to_owned()]),
2326            );
2327            decision
2328                .submit_passive_with_action_suppressions(
2329                    knowledge,
2330                    &mut ExperienceStore::new(),
2331                    &mut session,
2332                    &suppressions,
2333                )
2334                .unwrap()
2335        }
2336
2337        #[tokio::test]
2338        async fn rest_privacy_commits_require_receipts_and_accept_literal_candidate_replay_and_terminals(
2339        ) {
2340            let knowledge = KnowledgeBase::new();
2341            assert!(!validate(None, &knowledge, &terminal_state(), false).unwrap());
2342            let candidate = received_batch(true, DecisionExecutionStage::Passive);
2343            assert!(!validate(None, &knowledge, &parsed_state(&candidate), true).unwrap());
2344            let (knowledge, receipt) = committed(no_response_batch()).await;
2345            assert!(validate(Some(receipt), &knowledge, &terminal_state(), false).unwrap());
2346            let (knowledge, receipt) =
2347                committed(received_batch(false, DecisionExecutionStage::Passive)).await;
2348            assert!(validate(Some(receipt), &knowledge, &terminal_state(), true).unwrap());
2349
2350            let replay = received_batch(true, DecisionExecutionStage::Active);
2351            let state = RestExecutionState {
2352                legs: BTreeMap::from([
2353                    (
2354                        DecisionExecutionStage::Passive,
2355                        parsed_leg(&candidate, DecisionExecutionStage::Passive),
2356                    ),
2357                    (
2358                        DecisionExecutionStage::Active,
2359                        parsed_leg(&replay, DecisionExecutionStage::Active),
2360                    ),
2361                ]),
2362                ..RestExecutionState::default()
2363            };
2364            let (knowledge, first) = committed(candidate).await;
2365            let holder = decision_holder(&knowledge, &first);
2366            let second = commit_route(
2367                &knowledge,
2368                replay,
2369                "web.review.rest.readonly-replay@1",
2370                "http.rest-review",
2371                DecisionExecutionStage::Active,
2372            )
2373            .await;
2374            let accounting = RequestAccountingBroker::new(RuntimeBudget::default());
2375            for stage in [
2376                DecisionExecutionStage::Passive,
2377                DecisionExecutionStage::Active,
2378            ] {
2379                let mut lease = accounting
2380                    .try_begin(
2381                        "web.review.rest.readonly-replay@1",
2382                        stage,
2383                        (stage == DecisionExecutionStage::Passive)
2384                            .then_some(DecisionActionOrigin::Planned),
2385                    )
2386                    .unwrap();
2387                assert_eq!(lease.observe_response_bytes(8), 8);
2388                lease.finish(TransportDispatchOutcome::Completed);
2389            }
2390            let mut standard = report(Some(second), false);
2391            standard
2392                .turns
2393                .push(StandardWebDecisionRuntimeTurn::Outcome {
2394                    evidence: Box::new(first),
2395                    decision: Box::new(holder),
2396                });
2397            standard.transport = accounting.dispatch_audit();
2398            assert!(validate_value_free_rest_commits(
2399                &standard,
2400                &knowledge,
2401                &subject(),
2402                Some(&super::selection()),
2403                &state
2404            )
2405            .unwrap());
2406            // Merely staging an extra leg cannot authorize it when only the candidate
2407            // receipt was committed. This is not a fabricated successful replay.
2408            let staged_candidate = received_batch(true, DecisionExecutionStage::Passive);
2409            let staged_replay = received_batch(true, DecisionExecutionStage::Active);
2410            let staged_state = RestExecutionState {
2411                legs: BTreeMap::from([
2412                    (
2413                        DecisionExecutionStage::Passive,
2414                        parsed_leg(&staged_candidate, DecisionExecutionStage::Passive),
2415                    ),
2416                    (
2417                        DecisionExecutionStage::Active,
2418                        parsed_leg(&staged_replay, DecisionExecutionStage::Active),
2419                    ),
2420                ]),
2421                ..RestExecutionState::default()
2422            };
2423            let (knowledge, first) = committed(staged_candidate).await;
2424            assert!(!validate(Some(first), &knowledge, &staged_state, true).unwrap());
2425        }
2426
2427        #[tokio::test]
2428        async fn rest_privacy_commits_reject_rate_contradiction() {
2429            let batch = received_batch(true, DecisionExecutionStage::Passive);
2430            let state = parsed_state(&batch);
2431            let (_, template) = committed(batch.clone()).await;
2432            let mut contradicted = batch.clone();
2433            let item = &batch[6];
2434            contradicted[6] = replace(
2435                item,
2436                item.kind().clone(),
2437                EvidenceValue::Boolean(true),
2438                item.source().clone(),
2439                None,
2440            );
2441            let (knowledge, receipt) = mutation(&template, contradicted);
2442            assert!(
2443                validate(Some(receipt), &knowledge, &state, true).is_err(),
2444                "a committed rate-limit=true cannot support a positive rate_limited=false view"
2445            );
2446        }
2447
2448        #[tokio::test]
2449        async fn rest_privacy_commits_reject_terminal_media_spoofs() {
2450            let batch = received_batch(false, DecisionExecutionStage::Passive);
2451            let (_, template) = committed(batch.clone()).await;
2452            let compatibility = &batch[10];
2453            let wrong_source = EvidenceSource::new("http.rest-review", "synthetic-wrong-method")
2454                .unwrap()
2455                .with_correlation_id(CASE)
2456                .unwrap();
2457            let replacements = [
2458                replace(
2459                    compatibility,
2460                    EvidenceKind::Content,
2461                    compatibility.value().clone(),
2462                    compatibility.source().clone(),
2463                    None,
2464                ),
2465                replace(
2466                    compatibility,
2467                    compatibility.kind().clone(),
2468                    compatibility.value().clone(),
2469                    wrong_source,
2470                    None,
2471                ),
2472                replace(
2473                    compatibility,
2474                    compatibility.kind().clone(),
2475                    EvidenceValue::Text("true".to_owned()),
2476                    compatibility.source().clone(),
2477                    None,
2478                ),
2479                replace(
2480                    compatibility,
2481                    compatibility.kind().clone(),
2482                    EvidenceValue::Unsigned(1),
2483                    compatibility.source().clone(),
2484                    None,
2485                ),
2486                replace(
2487                    compatibility,
2488                    compatibility.kind().clone(),
2489                    EvidenceValue::Text("null".to_owned()),
2490                    compatibility.source().clone(),
2491                    None,
2492                ),
2493            ];
2494            for replacement in replacements {
2495                let mut changed = batch.clone();
2496                changed[10] = replacement;
2497                let (knowledge, receipt) = mutation(&template, changed);
2498                assert!(validate(Some(receipt), &knowledge, &terminal_state(), true).is_err());
2499            }
2500            let mut missing = batch.clone();
2501            missing.remove(10);
2502            let (knowledge, receipt) = mutation(&template, missing);
2503            assert!(validate(Some(receipt), &knowledge, &terminal_state(), true).is_err());
2504            let mut duplicate = batch;
2505            duplicate.push(fact(
2506                EvidenceKind::Http,
2507                HttpEvidencePredicate::RESPONSE_MEDIA_TYPE_JSON_COMPATIBLE.into_knowledge(),
2508                EvidenceValue::Boolean(true),
2509                "response-media-type-json-compatibility",
2510            ));
2511            let (knowledge, receipt) = mutation(&template, duplicate);
2512            assert!(validate(Some(receipt), &knowledge, &terminal_state(), true).is_err());
2513            // Core EvidenceValue is a tagged typed enum, so JSON null cannot become an
2514            // evidence row at all; Text("null") above separately tests a wrong typed row.
2515            assert!(serde_json::from_value::<EvidenceValue>(serde_json::Value::Null).is_err());
2516        }
2517
2518        #[tokio::test]
2519        async fn rest_privacy_commits_reject_wrong_route_stage_executor_subject_and_write_authority(
2520        ) {
2521            for (action, executor, stage) in [
2522                (
2523                    "synthetic.other-action",
2524                    "http.rest-review",
2525                    DecisionExecutionStage::Passive,
2526                ),
2527                (
2528                    "web.review.rest.readonly-replay@1",
2529                    "synthetic.other-executor",
2530                    DecisionExecutionStage::Passive,
2531                ),
2532                (
2533                    "web.review.rest.readonly-replay@1",
2534                    "http.rest-review",
2535                    DecisionExecutionStage::Active,
2536                ),
2537            ] {
2538                let knowledge = KnowledgeBase::new();
2539                let receipt =
2540                    commit_route(&knowledge, no_response_batch(), action, executor, stage).await;
2541                assert!(validate(Some(receipt), &knowledge, &terminal_state(), false).is_err());
2542            }
2543            let (knowledge, receipt) = committed(no_response_batch()).await;
2544            assert!(validate_value_free_rest_commits(
2545                &report(Some(receipt), false),
2546                &knowledge,
2547                &EntityId::new("endpoint:https://sibling.invalid/").unwrap(),
2548                Some(&super::selection()),
2549                &terminal_state()
2550            )
2551            .is_err());
2552            let batch = received_batch(true, DecisionExecutionStage::Passive);
2553            let state = parsed_state(&batch);
2554            let (knowledge, receipt) = committed(batch).await;
2555            assert!(validate_value_free_rest_commits(
2556                &report(Some(receipt), true),
2557                &knowledge,
2558                &subject(),
2559                None,
2560                &state
2561            )
2562            .is_err());
2563
2564            let batch = received_batch(true, DecisionExecutionStage::Passive);
2565            let state = parsed_state(&batch);
2566            let (knowledge, receipt) = committed(batch.clone()).await;
2567            assert!(validate(Some(receipt.clone()), &KnowledgeBase::new(), &state, true).is_err());
2568            let mut duplicate = batch;
2569            duplicate.push(duplicate[0].clone());
2570            let duplicated = receipt.with_test_committed_batch(
2571                duplicate.clone(),
2572                vec![KnowledgeWrite::Unchanged; duplicate.len()],
2573                knowledge.snapshot_for_subject(&subject()),
2574            );
2575            assert!(validate(Some(duplicated), &knowledge, &state, true).is_err());
2576            let wrong_writes = receipt.with_test_committed_batch(
2577                receipt.evidence().to_vec(),
2578                Vec::new(),
2579                knowledge.snapshot_for_subject(&subject()),
2580            );
2581            assert!(validate(Some(wrong_writes), &knowledge, &state, true).is_err());
2582        }
2583
2584        #[tokio::test]
2585        async fn rest_privacy_commits_reject_content_identities_unknown_facts_and_lost_derivation_parents(
2586        ) {
2587            let batch = received_batch(true, DecisionExecutionStage::Passive);
2588            let state = parsed_state(&batch);
2589            let (_, template) = committed(batch.clone()).await;
2590            for extra in [
2591                fact(
2592                    EvidenceKind::Content,
2593                    HttpEvidencePredicate::RESPONSE_BODY_SHA256.into_knowledge(),
2594                    EvidenceValue::Text("a".repeat(64)),
2595                    "response-body-sha256",
2596                ),
2597                custom(
2598                    "semantic_digest",
2599                    EvidenceValue::Text("synthetic-unkeyed-content-identity".to_owned()),
2600                    "semantic-digest",
2601                ),
2602                fact(
2603                    EvidenceKind::Custom("passive-secret-exposure".to_owned()),
2604                    KnowledgePredicate::new("web.secret-exposure", "response_body_lineage")
2605                        .unwrap(),
2606                    EvidenceValue::Boolean(true),
2607                    "synthetic-extra-lineage",
2608                ),
2609            ] {
2610                let mut changed = batch.clone();
2611                changed.push(extra);
2612                let (knowledge, receipt) = mutation(&template, changed);
2613                assert!(validate(Some(receipt), &knowledge, &state, true).is_err());
2614            }
2615            for index in [11, 12] {
2616                let mut changed = batch.clone();
2617                let item = &batch[index];
2618                let parents = item.origin().derivation().unwrap().parents()[1..].to_vec();
2619                changed[index] = replace(
2620                    item,
2621                    item.kind().clone(),
2622                    item.value().clone(),
2623                    item.source().clone(),
2624                    Some(parents),
2625                );
2626                let (knowledge, receipt) = mutation(&template, changed);
2627                assert!(validate(Some(receipt), &knowledge, &state, true).is_err());
2628            }
2629        }
2630    }
2631}