Skip to main content

termivar_scanner/web_runtime/
openapi_runtime.rs

1//! Parent-native, explicitly enabled OpenAPI document observation.
2
3use std::{
4    collections::BTreeMap,
5    fmt,
6    sync::{Arc, Mutex},
7};
8
9use async_trait::async_trait;
10use serde::Serialize;
11use sha2::{Digest, Sha256};
12use termivar_core::predicates::WebDiscoveryEvidencePredicate;
13use termivar_core::{
14    ConfidenceScore, DerivationAlgorithm, EntityId, Evidence, EvidenceDerivation, EvidenceId,
15    EvidenceKind, EvidenceSource, EvidenceValue, HttpEvidencePredicate, KnowledgePredicate,
16};
17use url::Url;
18
19#[cfg(feature = "rest-review")]
20use super::rest_runtime::StableRestSelectionSlot;
21#[cfg(all(feature = "ssrf-oast-review", feature = "openapi-review"))]
22use super::ssrf_oast_runtime::StableSsrfOastSelectionSlot;
23use super::{
24    assessment_defense::{project_assessment_defense_signal, AssessmentDefenseProjectionContext},
25    assessment_item::{
26        AssessmentCapabilityDescriptor, AssessmentItemProjectionError, AssessmentItemTarget,
27        AssessmentProjectionContext,
28    },
29};
30#[cfg(feature = "rest-review")]
31use crate::rest_review::{
32    select_rest_operation, RestOperationSelection, RestOperationSelectionOutcome,
33};
34#[cfg(all(feature = "ssrf-oast-review", feature = "openapi-review"))]
35use crate::ssrf_oast_review::{
36    select_openapi_document_query_candidate, SsrfOastCandidateSelection, SsrfOastQueryCandidate,
37};
38use crate::{
39    http_evidence::{HttpRequestBroker, HttpRequestBrokerError},
40    openapi_review::{
41        parse_openapi_document, OpenApiHttpMethod, OpenApiParseOutcome, OpenApiVersion,
42    },
43    DecisionActionExecutor, DecisionActionOrigin, DecisionExecutionRequest, DecisionExecutionStage,
44    DecisionExecutorError, DecisionExecutorRegistry, HttpProbe, HttpProbeMethod, KnowledgeBase,
45    RuleEngine, RuntimeLimitExceeded, StandardApiReasoning, TransportDispatchOutcome,
46    HTTP_EVIDENCE_EXECUTOR_ID,
47};
48
49pub const OPENAPI_REVIEW_ACTION_ID: &str = "web.review.openapi.document-replay@1";
50pub const OPENAPI_REVIEW_CAPABILITY_ID: &str = "api.openapi-contract-observed@1";
51pub const MAX_OPENAPI_REVIEW_DOCUMENTS: usize = 1;
52pub const MAX_OPENAPI_REVIEW_REQUESTS: usize = 2;
53pub const MAX_OPENAPI_REVIEW_ACTIVE_VERIFICATIONS: usize = 1;
54pub(super) const OPENAPI_REVIEW_ACTION_CYCLE_ALLOWANCE: u32 = 1;
55
56const OPENAPI_EXECUTOR_ID: &str = "http.openapi-review";
57const OPENAPI_EVIDENCE_NAMESPACE: &str = "web.openapi-review.transport";
58const OPENAPI_ACCEPT: &str = "application/vnd.oai.openapi+json, application/json, application/yaml, application/x-yaml, text/yaml, text/plain";
59const OPENAPI_DOCUMENT_ID_DOMAIN: &[u8] = b"security.openapi-review.document-target.v1\0";
60const MAX_OPENAPI_CANDIDATE_HINTS: usize = 64;
61const MAX_OPENAPI_CANDIDATE_URL_BYTES: usize = 8 * 1024;
62const MAX_OPENAPI_CANDIDATE_PATH_BYTES: usize = 1024;
63
64/// An assessment selection, never inferred from a remote document or match.
65#[derive(Clone, Copy, Debug, Eq, PartialEq)]
66enum OpenApiResponsePrivacy {
67    HistoricalDigests,
68    #[cfg(feature = "secret-exposure-review")]
69    ValueFree,
70}
71
72impl OpenApiResponsePrivacy {
73    fn is_value_free(self) -> bool {
74        match self {
75            Self::HistoricalDigests => false,
76            #[cfg(feature = "secret-exposure-review")]
77            Self::ValueFree => true,
78        }
79    }
80}
81
82const OPENAPI_CLASSIFICATION_ALGORITHM: &str = "web.openapi-review.value-free-classification";
83
84#[cfg(feature = "secret-exposure-review")]
85pub(super) fn openapi_privacy_route_is_valid(
86    case: &crate::VerificationCase,
87    stage: DecisionExecutionStage,
88    executor_id: &str,
89) -> bool {
90    executor_id == OPENAPI_EXECUTOR_ID
91        && case.action_id() == OPENAPI_REVIEW_ACTION_ID
92        && !case.applies_hypothesis_transition()
93        && case.payload_strategy().is_none()
94        && matches!(
95            stage,
96            DecisionExecutionStage::Passive | DecisionExecutionStage::Active
97        )
98}
99
100const OPENAPI_CAPABILITY: AssessmentCapabilityDescriptor = AssessmentCapabilityDescriptor::informational(
101    OPENAPI_REVIEW_CAPABILITY_ID,
102    "OpenAPI contract observed",
103    "API surface",
104    "Two anonymous exact-origin GET requests reproduced the same bounded OpenAPI contract metadata.",
105    900_000,
106    "api.openapi-contract-review@1",
107    "Confirm that publishing this OpenAPI contract matches deployment policy.",
108);
109
110#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize)]
111#[serde(rename_all = "snake_case")]
112pub enum OpenApiRuntimeOutcome {
113    NotEligible,
114    DocumentObserved,
115    Swagger20MetadataOnly,
116    UnsupportedVersion,
117    ReplayMismatch,
118    UnsupportedMedia,
119    Malformed,
120    LimitExceeded,
121    TooLarge,
122    RedirectObserved,
123    RateLimited,
124    DefensiveInterference,
125    HttpError,
126    Truncated,
127    Incomplete,
128    BudgetExhausted,
129    Cancelled,
130}
131
132#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize)]
133#[serde(rename_all = "snake_case")]
134pub enum OpenApiCandidateSource {
135    DiscoveredOpenApiJson,
136    DiscoveredOpenApiYaml,
137    DiscoveredSwaggerJson,
138    DiscoveredSwaggerYaml,
139    ConventionalOpenApiJson,
140}
141impl OpenApiCandidateSource {
142    pub const fn as_str(self) -> &'static str {
143        match self {
144            Self::DiscoveredOpenApiJson => "discovered_openapi_json",
145            Self::DiscoveredOpenApiYaml => "discovered_openapi_yaml",
146            Self::DiscoveredSwaggerJson => "discovered_swagger_json",
147            Self::DiscoveredSwaggerYaml => "discovered_swagger_yaml",
148            Self::ConventionalOpenApiJson => "conventional_openapi_json",
149        }
150    }
151}
152
153#[derive(Clone, PartialEq, Eq, Serialize)]
154pub struct WebAssessmentOpenApiAudit {
155    outcome: OpenApiRuntimeOutcome,
156    candidate_source: OpenApiCandidateSource,
157    request_count: u8,
158    active_verification_count: u8,
159    version: Option<&'static str>,
160    semantic_digest: Option<String>,
161    path_count: u32,
162    operation_count: u32,
163    get_operation_count: u32,
164    write_operation_count: u32,
165    path_parameter_count: u32,
166    query_parameter_count: u32,
167    explicit_auth_operation_count: u32,
168    anonymous_operation_count: u32,
169    url_like_operation_count: u32,
170    multipart_operation_count: u32,
171    deprecated_operation_count: u32,
172    replay_matched: bool,
173    item_projected: bool,
174}
175
176impl WebAssessmentOpenApiAudit {
177    pub const fn outcome(&self) -> OpenApiRuntimeOutcome {
178        self.outcome
179    }
180    pub const fn candidate_source(&self) -> OpenApiCandidateSource {
181        self.candidate_source
182    }
183    pub const fn request_count(&self) -> u8 {
184        self.request_count
185    }
186    pub const fn active_verification_count(&self) -> u8 {
187        self.active_verification_count
188    }
189    pub const fn version(&self) -> Option<&'static str> {
190        self.version
191    }
192    pub fn semantic_digest(&self) -> Option<&str> {
193        self.semantic_digest.as_deref()
194    }
195    pub const fn path_count(&self) -> u32 {
196        self.path_count
197    }
198    pub const fn operation_count(&self) -> u32 {
199        self.operation_count
200    }
201    pub const fn get_operation_count(&self) -> u32 {
202        self.get_operation_count
203    }
204    pub const fn write_operation_count(&self) -> u32 {
205        self.write_operation_count
206    }
207    pub const fn path_parameter_count(&self) -> u32 {
208        self.path_parameter_count
209    }
210    pub const fn query_parameter_count(&self) -> u32 {
211        self.query_parameter_count
212    }
213    pub const fn explicit_auth_operation_count(&self) -> u32 {
214        self.explicit_auth_operation_count
215    }
216    pub const fn anonymous_operation_count(&self) -> u32 {
217        self.anonymous_operation_count
218    }
219    pub const fn url_like_operation_count(&self) -> u32 {
220        self.url_like_operation_count
221    }
222    pub const fn multipart_operation_count(&self) -> u32 {
223        self.multipart_operation_count
224    }
225    pub const fn deprecated_operation_count(&self) -> u32 {
226        self.deprecated_operation_count
227    }
228    pub const fn replay_matched(&self) -> bool {
229        self.replay_matched
230    }
231    pub const fn item_projected(&self) -> bool {
232        self.item_projected
233    }
234}
235
236impl fmt::Debug for WebAssessmentOpenApiAudit {
237    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
238        f.debug_struct("WebAssessmentOpenApiAudit")
239            .field("outcome", &self.outcome)
240            .field("candidate_source", &self.candidate_source)
241            .field("request_count", &self.request_count)
242            .field("active_verification_count", &self.active_verification_count)
243            .field("version", &self.version)
244            .field("path_count", &self.path_count)
245            .field("operation_count", &self.operation_count)
246            .field("get_operation_count", &self.get_operation_count)
247            .field("write_operation_count", &self.write_operation_count)
248            .field("path_parameter_count", &self.path_parameter_count)
249            .field("query_parameter_count", &self.query_parameter_count)
250            .field(
251                "explicit_auth_operation_count",
252                &self.explicit_auth_operation_count,
253            )
254            .field("anonymous_operation_count", &self.anonymous_operation_count)
255            .field("url_like_operation_count", &self.url_like_operation_count)
256            .field("multipart_operation_count", &self.multipart_operation_count)
257            .field(
258                "deprecated_operation_count",
259                &self.deprecated_operation_count,
260            )
261            .field("replay_matched", &self.replay_matched)
262            .field("item_projected", &self.item_projected)
263            .finish()
264    }
265}
266
267#[derive(Clone, Eq, PartialEq)]
268pub(super) struct OpenApiCandidate {
269    url: Url,
270    source: OpenApiCandidateSource,
271    identity: String,
272}
273
274impl fmt::Debug for OpenApiCandidate {
275    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
276        f.write_str("OpenApiCandidate(<exact-origin-redacted>)")
277    }
278}
279
280pub(super) fn select_openapi_candidate(
281    origin: &Url,
282    hints: impl IntoIterator<Item = Url>,
283) -> Option<OpenApiCandidate> {
284    let mut candidates = hints
285        .into_iter()
286        .take(MAX_OPENAPI_CANDIDATE_HINTS)
287        .filter_map(|url| {
288            if !candidate_url_is_allowed(origin, &url) {
289                return None;
290            }
291            let name = url.path_segments()?.next_back()?;
292            let source = match name.to_ascii_lowercase().as_str() {
293                "openapi.json" => OpenApiCandidateSource::DiscoveredOpenApiJson,
294                "openapi.yaml" | "openapi.yml" => OpenApiCandidateSource::DiscoveredOpenApiYaml,
295                "swagger.json" => OpenApiCandidateSource::DiscoveredSwaggerJson,
296                "swagger.yaml" | "swagger.yml" => OpenApiCandidateSource::DiscoveredSwaggerYaml,
297                _ => return None,
298            };
299            Some((source, url))
300        })
301        .collect::<Vec<_>>();
302    candidates.sort_by(|(left_source, left), (right_source, right)| {
303        candidate_rank(*left_source)
304            .cmp(&candidate_rank(*right_source))
305            .then_with(|| left.as_str().cmp(right.as_str()))
306    });
307    let (source, url) = candidates.into_iter().next().unwrap_or_else(|| {
308        (
309            OpenApiCandidateSource::ConventionalOpenApiJson,
310            origin
311                .join("/openapi.json")
312                .expect("absolute fallback path is valid"),
313        )
314    });
315    if !candidate_url_is_allowed(origin, &url) {
316        return None;
317    }
318    Some(OpenApiCandidate {
319        identity: document_identity(&url),
320        url,
321        source,
322    })
323}
324
325fn candidate_url_is_allowed(origin: &Url, url: &Url) -> bool {
326    matches!(url.scheme(), "http" | "https")
327        && url.origin() == origin.origin()
328        && url.username().is_empty()
329        && url.password().is_none()
330        && url.query().is_none()
331        && url.fragment().is_none()
332        && url.as_str().len() <= MAX_OPENAPI_CANDIDATE_URL_BYTES
333        && url.path().len() <= MAX_OPENAPI_CANDIDATE_PATH_BYTES
334}
335
336fn committed_discovery_hints(knowledge: &KnowledgeBase, subject: &EntityId) -> Vec<Url> {
337    knowledge
338        .evidence_for_subject(subject)
339        .into_iter()
340        .filter_map(|evidence| {
341            let expected_method = if evidence.predicate()
342                == &WebDiscoveryEvidencePredicate::GET_ROUTE.into_knowledge()
343            {
344                "get-route"
345            } else if evidence.predicate()
346                == &WebDiscoveryEvidencePredicate::HEAD_ROUTE.into_knowledge()
347            {
348                "head-route"
349            } else if evidence.predicate()
350                == &WebDiscoveryEvidencePredicate::GET_FORM_ACTION.into_knowledge()
351            {
352                "get-form-action"
353            } else if evidence.predicate()
354                == &WebDiscoveryEvidencePredicate::POST_FORM_ACTION.into_knowledge()
355            {
356                "post-form-action"
357            } else if evidence.predicate()
358                == &WebDiscoveryEvidencePredicate::DIALOG_FORM_ACTION.into_knowledge()
359            {
360                "dialog-form-action"
361            } else {
362                return None;
363            };
364            if evidence.kind() != &EvidenceKind::Content
365                || evidence.source().component() != HTTP_EVIDENCE_EXECUTOR_ID
366                || evidence.source().method() != expected_method
367                || evidence.source().correlation_id() != Some(super::BOOTSTRAP_CASE_ID)
368                || evidence.origin().derivation().is_none()
369            {
370                return None;
371            }
372            let EvidenceValue::Text(value) = evidence.value() else {
373                return None;
374            };
375            Url::parse(value).ok()
376        })
377        .take(MAX_OPENAPI_CANDIDATE_HINTS)
378        .collect()
379}
380
381const fn candidate_rank(source: OpenApiCandidateSource) -> u8 {
382    match source {
383        OpenApiCandidateSource::DiscoveredOpenApiJson => 0,
384        OpenApiCandidateSource::DiscoveredOpenApiYaml => 1,
385        OpenApiCandidateSource::DiscoveredSwaggerJson => 2,
386        OpenApiCandidateSource::DiscoveredSwaggerYaml => 3,
387        OpenApiCandidateSource::ConventionalOpenApiJson => 4,
388    }
389}
390
391fn document_identity(url: &Url) -> String {
392    let mut digest = Sha256::new();
393    digest.update(OPENAPI_DOCUMENT_ID_DOMAIN);
394    digest.update(url.path().as_bytes());
395    format!("openapi-document@1:{:x}", digest.finalize())
396}
397
398fn observed_document_identity(candidate_identity: &str, semantic_digest: &str) -> String {
399    let mut digest = Sha256::new();
400    digest.update(b"security.openapi-review.observed-document.v1\0");
401    digest.update(crate::openapi_review::OPENAPI_CATALOG_ALGORITHM.as_bytes());
402    digest.update(candidate_identity.as_bytes());
403    digest.update(semantic_digest.as_bytes());
404    format!("openapi-observed@1:{:x}", digest.finalize())
405}
406
407pub(super) struct OpenApiReviewConfig {
408    candidate: OpenApiCandidate,
409    privacy: OpenApiResponsePrivacy,
410}
411impl OpenApiReviewConfig {
412    pub(super) fn new(candidate: OpenApiCandidate) -> Self {
413        Self {
414            candidate,
415            privacy: OpenApiResponsePrivacy::HistoricalDigests,
416        }
417    }
418
419    #[cfg(feature = "secret-exposure-review")]
420    pub(super) fn with_value_free_response_privacy(mut self) -> Self {
421        self.privacy = OpenApiResponsePrivacy::ValueFree;
422        self
423    }
424}
425
426pub(super) struct OpenApiRuntimeBinding {
427    executor: Arc<OpenApiDecisionExecutor>,
428    subject: EntityId,
429}
430
431impl OpenApiRuntimeBinding {
432    pub(super) fn new(
433        config: OpenApiReviewConfig,
434        requests: HttpRequestBroker,
435        subject: EntityId,
436        knowledge: KnowledgeBase,
437    ) -> Self {
438        Self::new_internal(
439            config,
440            requests,
441            subject,
442            knowledge,
443            #[cfg(feature = "ssrf-oast-review")]
444            None,
445        )
446    }
447
448    #[cfg(all(feature = "ssrf-oast-review", feature = "openapi-review"))]
449    pub(super) fn new_with_ssrf_selection(
450        config: OpenApiReviewConfig,
451        requests: HttpRequestBroker,
452        subject: EntityId,
453        knowledge: KnowledgeBase,
454        ssrf_selection: StableSsrfOastSelectionSlot,
455    ) -> Self {
456        Self::new_internal(config, requests, subject, knowledge, Some(ssrf_selection))
457    }
458
459    fn new_internal(
460        config: OpenApiReviewConfig,
461        requests: HttpRequestBroker,
462        subject: EntityId,
463        knowledge: KnowledgeBase,
464        #[cfg(all(feature = "ssrf-oast-review", feature = "openapi-review"))]
465        ssrf_selection: Option<StableSsrfOastSelectionSlot>,
466    ) -> Self {
467        let candidate = config.candidate;
468        let executor = Arc::new(OpenApiDecisionExecutor {
469            requests,
470            privacy: config.privacy,
471            candidate: Mutex::new(candidate),
472            subject: subject.clone(),
473            knowledge,
474            state: Mutex::new(OpenApiExecutionState::default()),
475            #[cfg(feature = "rest-review")]
476            rest_selection: StableRestSelectionSlot::new(),
477            #[cfg(all(feature = "ssrf-oast-review", feature = "openapi-review"))]
478            ssrf_selection,
479        });
480        Self { executor, subject }
481    }
482    #[cfg(feature = "rest-review")]
483    pub(super) fn rest_selection_slot(&self) -> StableRestSelectionSlot {
484        self.executor.rest_selection.clone()
485    }
486    #[cfg(feature = "rest-review")]
487    pub(super) fn response_privacy_is_value_free(&self) -> bool {
488        self.executor.privacy.is_value_free()
489    }
490    pub(super) fn install_into_parent_registry(
491        &self,
492        registry: &mut DecisionExecutorRegistry,
493    ) -> Result<(), OpenApiRuntimeInvariantError> {
494        let before = registry.len();
495        let executor: Arc<dyn DecisionActionExecutor> = self.executor.clone();
496        registry
497            .register(executor)
498            .map_err(|_| OpenApiRuntimeInvariantError::Catalog)?;
499        for stage in [
500            DecisionExecutionStage::Passive,
501            DecisionExecutionStage::Active,
502        ] {
503            registry
504                .route_action(stage, OPENAPI_REVIEW_ACTION_ID, OPENAPI_EXECUTOR_ID)
505                .map_err(|_| OpenApiRuntimeInvariantError::Catalog)?;
506        }
507        if registry.len() != before + 1 {
508            return Err(OpenApiRuntimeInvariantError::Catalog);
509        }
510        Ok(())
511    }
512    pub(super) fn finalize(
513        self,
514        knowledge: &KnowledgeBase,
515        standard: &super::StandardWebDecisionRunReport,
516        forced_outcome: Option<OpenApiRuntimeOutcome>,
517        forced_runtime_limit: Option<RuntimeLimitExceeded>,
518    ) -> Result<OpenApiRuntimeResult, OpenApiRuntimeInvariantError> {
519        let transport = standard.transport();
520        if transport.omitted_receipt_count() != 0 {
521            return Err(OpenApiRuntimeInvariantError::Catalog);
522        }
523        let selected_candidate = self
524            .executor
525            .candidate
526            .lock()
527            .map_err(|_| OpenApiRuntimeInvariantError::Catalog)?
528            .clone();
529        let receipts = transport
530            .receipts()
531            .iter()
532            .filter(|r| r.action_id() == OPENAPI_REVIEW_ACTION_ID)
533            .collect::<Vec<_>>();
534        if !openapi_transport_prefix_is_valid(&receipts) {
535            return Err(OpenApiRuntimeInvariantError::Catalog);
536        }
537        let state = self.executor.take_state()?;
538        #[cfg(feature = "secret-exposure-review")]
539        let committed_legs = if self.executor.privacy.is_value_free() {
540            validate_value_free_openapi_commits(
541                standard,
542                knowledge,
543                &self.subject,
544                &selected_candidate,
545                &state,
546            )?
547        } else {
548            true
549        };
550        #[cfg(not(feature = "secret-exposure-review"))]
551        let committed_legs = true;
552        if !captured_openapi_prefix_reconciles(&state, &receipts) {
553            return Err(OpenApiRuntimeInvariantError::Catalog);
554        }
555        let request_count = u8::try_from(receipts.len()).unwrap_or(u8::MAX);
556        let state_terminal = state.terminal;
557        let terminal = forced_outcome.or_else(|| {
558            committed_legs
559                .then(|| state_terminal.as_ref().map(|(outcome, _)| *outcome))
560                .flatten()
561        });
562        let runtime_limit = if forced_outcome.is_some() {
563            forced_runtime_limit
564        } else {
565            state_terminal.and_then(|(_, limit)| limit)
566        };
567        if let Some(outcome) = terminal {
568            let audit = audit(
569                selected_candidate.source,
570                outcome,
571                request_count,
572                None,
573                false,
574            );
575            if matches!(
576                outcome,
577                OpenApiRuntimeOutcome::Incomplete
578                    | OpenApiRuntimeOutcome::Truncated
579                    | OpenApiRuntimeOutcome::LimitExceeded
580                    | OpenApiRuntimeOutcome::TooLarge
581                    | OpenApiRuntimeOutcome::BudgetExhausted
582                    | OpenApiRuntimeOutcome::Cancelled
583            ) {
584                return Ok(OpenApiRuntimeResult::Stopped {
585                    audit,
586                    runtime_limit,
587                });
588            }
589            return Ok(OpenApiRuntimeResult::Complete(CommittedOpenApiReview {
590                subject: self.subject,
591                privacy: self.executor.privacy,
592                target_identity: selected_candidate.identity,
593                outcome,
594                evidence_ids: Vec::new(),
595                audit,
596            }));
597        }
598        let (Some(candidate), Some(replay)) = (
599            state.legs.get(&DecisionExecutionStage::Passive),
600            state.legs.get(&DecisionExecutionStage::Active),
601        ) else {
602            return Ok(OpenApiRuntimeResult::Stopped {
603                audit: audit(
604                    selected_candidate.source,
605                    if receipts.is_empty() {
606                        OpenApiRuntimeOutcome::NotEligible
607                    } else {
608                        OpenApiRuntimeOutcome::Incomplete
609                    },
610                    request_count,
611                    None,
612                    false,
613                ),
614                runtime_limit: None,
615            });
616        };
617        if !committed_legs
618            || receipts.len() != MAX_OPENAPI_REVIEW_REQUESTS
619            || receipts
620                .iter()
621                .zip([candidate, replay])
622                .any(|(receipt, leg)| {
623                    receipt.outcome() != TransportDispatchOutcome::Completed
624                        || receipt.response_bytes() != leg.response_bytes
625                })
626        {
627            return Ok(OpenApiRuntimeResult::Stopped {
628                audit: audit(
629                    selected_candidate.source,
630                    OpenApiRuntimeOutcome::Incomplete,
631                    request_count,
632                    None,
633                    false,
634                ),
635                runtime_limit: None,
636            });
637        }
638        let outcome = if candidate.semantically_matches(replay) {
639            OpenApiRuntimeOutcome::DocumentObserved
640        } else {
641            OpenApiRuntimeOutcome::ReplayMismatch
642        };
643        let projected = outcome == OpenApiRuntimeOutcome::DocumentObserved;
644        let mut audit = audit(
645            selected_candidate.source,
646            outcome,
647            request_count,
648            Some(candidate),
649            projected,
650        );
651        if self.executor.privacy.is_value_free() {
652            audit.semantic_digest = None;
653        }
654        let evidence_ids = vec![
655            candidate
656                .evidence_id
657                .clone()
658                .ok_or(OpenApiRuntimeInvariantError::Catalog)?,
659            replay
660                .evidence_id
661                .clone()
662                .ok_or(OpenApiRuntimeInvariantError::Catalog)?,
663        ];
664        if evidence_ids
665            .iter()
666            .any(|id| knowledge.evidence(id).is_none())
667        {
668            return Err(OpenApiRuntimeInvariantError::Catalog);
669        }
670        if projected {
671            commit_api_reasoning_inputs(knowledge, &self.subject)?;
672        }
673        let target_identity = if self.executor.privacy.is_value_free() {
674            selected_candidate.identity
675        } else {
676            observed_document_identity(&selected_candidate.identity, &candidate.digest)
677        };
678        Ok(OpenApiRuntimeResult::Complete(CommittedOpenApiReview {
679            subject: self.subject,
680            privacy: self.executor.privacy,
681            target_identity,
682            outcome,
683            evidence_ids,
684            audit,
685        }))
686    }
687}
688
689/// Executor state is staged before the runner commits. It is usable for a
690/// public projection only after exact receipt/write-set reconciliation. A
691/// cancelled final dispatch may legitimately have no committed receipt.
692#[cfg(feature = "secret-exposure-review")]
693fn validate_value_free_openapi_commits(
694    standard: &super::StandardWebDecisionRunReport,
695    knowledge: &KnowledgeBase,
696    subject: &EntityId,
697    candidate: &OpenApiCandidate,
698    state: &OpenApiExecutionState,
699) -> Result<bool, OpenApiRuntimeInvariantError> {
700    use crate::{KnowledgeWrite, StandardWebDecisionRuntimeTurn};
701    use std::collections::BTreeSet;
702    let receipts = standard
703        .turns()
704        .iter()
705        .filter_map(|turn| match turn {
706            StandardWebDecisionRuntimeTurn::Outcome { evidence, .. } => Some(evidence.as_ref()),
707            StandardWebDecisionRuntimeTurn::Planning(_) => None,
708        })
709        .chain(standard.unverified_evidence())
710        .filter(|receipt| {
711            receipt.case().action_id() == OPENAPI_REVIEW_ACTION_ID
712                || receipt.executor_id() == OPENAPI_EXECUTOR_ID
713        })
714        .collect::<Vec<_>>();
715    if receipts.len() > MAX_OPENAPI_REVIEW_REQUESTS {
716        return Err(OpenApiRuntimeInvariantError::Catalog);
717    }
718    let mut committed = BTreeSet::new();
719    let mut terminal_committed = false;
720    for (index, receipt) in receipts.into_iter().enumerate() {
721        let expected_stage = if index == 0 {
722            DecisionExecutionStage::Passive
723        } else {
724            DecisionExecutionStage::Active
725        };
726        if !openapi_privacy_route_is_valid(receipt.case(), receipt.stage(), receipt.executor_id())
727            || receipt.case().subject() != subject
728            || receipt.stage() != expected_stage
729            || receipt.evidence().len() != receipt.writes().len()
730        {
731            return Err(OpenApiRuntimeInvariantError::Catalog);
732        }
733        let mut ids = BTreeSet::new();
734        for (item, write) in receipt.write_set() {
735            if !matches!(write, KnowledgeWrite::Inserted | KnowledgeWrite::Unchanged)
736                || item.subject() != subject
737                || item.source().component() != OPENAPI_EXECUTOR_ID
738                || item.source().correlation_id() != Some(receipt.case().id())
739                || item.reliability() != ConfidenceScore::MAX
740                || !ids.insert(item.id())
741                || knowledge.evidence(item.id()).as_ref() != Some(item)
742                || item.predicate() == &HttpEvidencePredicate::RESPONSE_BODY_SHA256.into_knowledge()
743            {
744                return Err(OpenApiRuntimeInvariantError::Catalog);
745            }
746        }
747        let response_received = receipt.evidence().iter().any(|item| {
748            item.predicate() == &HttpEvidencePredicate::REQUEST_METHOD.into_knowledge()
749        });
750        if !response_received {
751            // No-body failure is deliberately NOT a fabricated body lineage.
752            let bytes = openapi_receipt_fact(
753                receipt,
754                &HttpEvidencePredicate::RESPONSE_BODY_BYTES_OBSERVED.into_knowledge(),
755            )?;
756            let terminal = openapi_receipt_fact(
757                receipt,
758                &crate::web_actions::openapi_review_phase_terminal_predicate(),
759            )?;
760            if state.terminal.is_none()
761                || receipt.evidence().len() != 2
762                || state.legs.contains_key(&receipt.stage())
763                || bytes.kind() != &EvidenceKind::Custom("openapi-review".to_owned())
764                || terminal.kind() != &EvidenceKind::Custom("openapi-review".to_owned())
765                || !bytes.origin().is_direct()
766                || !terminal.origin().is_direct()
767                || bytes.source().method() != "response-body-size"
768                || terminal.source().method() != "phase-terminal"
769                || bytes.value() != &EvidenceValue::Unsigned(0)
770                || terminal.value() != &EvidenceValue::Boolean(true)
771            {
772                return Err(OpenApiRuntimeInvariantError::Catalog);
773            }
774            terminal_committed = true;
775            continue;
776        }
777        let parents = super::secret_exposure::openapi_response_privacy_parent_ids(
778            receipt.evidence(),
779            receipt.case(),
780            receipt.stage(),
781            receipt.executor_id(),
782        )
783        .ok_or(OpenApiRuntimeInvariantError::Catalog)?;
784        let lineage_predicate = KnowledgePredicate::new(
785            super::secret_exposure::SECRET_EXPOSURE_NAMESPACE,
786            super::secret_exposure::ACTIVE_REVIEW_BODY_LINEAGE,
787        )?;
788        let lineage = openapi_receipt_fact(receipt, &lineage_predicate)?;
789        if receipt.evidence().iter().any(|item| {
790            let known_openapi_fact = match item.predicate().name() {
791                "document" | "phase-terminal" => true,
792                // This pre-existing readiness marker is emitted in a build
793                // containing REST even if the REST consumer is not selected.
794                // It is a fixed boolean, never a catalogue content identity.
795                #[cfg(feature = "rest-review")]
796                "rest-catalog-ready" => {
797                    receipt.stage() == DecisionExecutionStage::Active
798                        && item.kind() == &EvidenceKind::Custom("openapi-review".to_owned())
799                        && item.source().method() == "rest-catalog-ready"
800                        && item.origin().is_direct()
801                        && item.value() == &EvidenceValue::Boolean(true)
802                },
803                _ => false,
804            };
805            (item.predicate().namespace() == super::secret_exposure::SECRET_EXPOSURE_NAMESPACE
806                && item.predicate() != &lineage_predicate)
807                || (item.predicate().namespace() == OPENAPI_EVIDENCE_NAMESPACE
808                    && !known_openapi_fact)
809        }) {
810            return Err(OpenApiRuntimeInvariantError::Catalog);
811        }
812        if !super::secret_exposure::is_active_review_body_privacy_lineage(
813            lineage,
814            subject,
815            receipt.case().id(),
816            OPENAPI_EXECUTOR_ID,
817            &parents,
818        ) {
819            return Err(OpenApiRuntimeInvariantError::Catalog);
820        }
821        for predicate in [
822            HttpEvidencePredicate::REQUEST_URL,
823            HttpEvidencePredicate::RESPONSE_FINAL_URL,
824        ] {
825            if openapi_receipt_fact(receipt, &predicate.into_knowledge())?.value()
826                != &EvidenceValue::Text(candidate.url.to_string())
827            {
828                return Err(OpenApiRuntimeInvariantError::Catalog);
829            }
830        }
831        let document_predicate = KnowledgePredicate::new(OPENAPI_EVIDENCE_NAMESPACE, "document")?;
832        let document_count = receipt
833            .evidence()
834            .iter()
835            .filter(|item| item.predicate() == &document_predicate)
836            .count();
837        let terminal = openapi_receipt_fact(
838            receipt,
839            &crate::web_actions::openapi_review_phase_terminal_predicate(),
840        )?;
841        if terminal.kind() != &EvidenceKind::Custom("openapi-review".to_owned())
842            || terminal.source().method() != "phase-terminal"
843            || !terminal.origin().is_direct()
844        {
845            return Err(OpenApiRuntimeInvariantError::Catalog);
846        }
847        let completion = receipt
848            .evidence()
849            .iter()
850            .filter(|item| {
851                item.predicate()
852                    == &crate::web_actions::native_web_review_response_marker_predicate()
853            })
854            .collect::<Vec<_>>();
855        let Some(leg) = state.legs.get(&receipt.stage()) else {
856            let wire = standard
857                .transport()
858                .receipts()
859                .iter()
860                .filter(|item| item.action_id() == OPENAPI_REVIEW_ACTION_ID)
861                .nth(index)
862                .ok_or(OpenApiRuntimeInvariantError::Catalog)?;
863            let bytes = openapi_receipt_fact(
864                receipt,
865                &HttpEvidencePredicate::RESPONSE_BODY_BYTES_OBSERVED.into_knowledge(),
866            )?;
867            if state.terminal.is_none()
868                || document_count != 0
869                || !completion.is_empty()
870                || terminal.value() != &EvidenceValue::Boolean(true)
871                || !matches!(
872                    wire.outcome(),
873                    TransportDispatchOutcome::Completed
874                        | TransportDispatchOutcome::ResponseBudgetReached
875                )
876                || !matches!(bytes.value(), EvidenceValue::Unsigned(value) if *value <= wire.response_bytes())
877            {
878                return Err(OpenApiRuntimeInvariantError::Catalog);
879            }
880            terminal_committed = true;
881            continue;
882        };
883        let document = openapi_receipt_fact(receipt, &document_predicate)?;
884        let derivation = document
885            .origin()
886            .derivation()
887            .ok_or(OpenApiRuntimeInvariantError::Catalog)?;
888        let expected_summary = EvidenceValue::TextList(vec![
889            format!("version={}", leg.version.unwrap_or("unknown")),
890            format!("paths={}", leg.paths),
891            format!("operations={}", leg.operations),
892        ]);
893        let phase = crate::web_actions::openapi_review_phase_terminal_predicate();
894        let direct_predicates = [
895            HttpEvidencePredicate::REQUEST_METHOD,
896            HttpEvidencePredicate::REQUEST_URL,
897            HttpEvidencePredicate::RESPONSE_STATUS,
898            HttpEvidencePredicate::RESPONSE_FINAL_URL,
899            HttpEvidencePredicate::RESPONSE_BODY_BYTES_OBSERVED,
900            HttpEvidencePredicate::RESPONSE_BODY_TRUNCATED,
901            HttpEvidencePredicate::RATE_LIMIT_DETECTED,
902            HttpEvidencePredicate::RATE_LIMIT_ADVERTISED,
903            HttpEvidencePredicate::RESPONSE_MEDIA_TYPE,
904            HttpEvidencePredicate::RESPONSE_MEDIA_TYPE_JSON_COMPATIBLE,
905        ]
906        .map(|predicate| predicate.into_knowledge());
907        let mut expected_parents = BTreeSet::new();
908        for item in receipt.evidence().iter().filter(|item| {
909            direct_predicates.contains(item.predicate()) || item.predicate() == &phase
910        }) {
911            if !item.origin().is_direct() || item.reliability() != document.reliability() {
912                return Err(OpenApiRuntimeInvariantError::Catalog);
913            }
914            expected_parents.insert(item.id().clone());
915        }
916        expected_parents.insert(lineage.id().clone());
917        if leg.evidence_id.as_ref() != Some(document.id())
918            || completion.len() != 1
919            || completion[0].value() != &EvidenceValue::Boolean(true)
920            || !completion[0].origin().is_direct()
921            || completion[0].source().method() != "complete"
922            || document.kind() != &EvidenceKind::Custom("openapi-review".to_owned())
923            || document.source().method() != "document"
924            || document.value() != &expected_summary
925            || derivation.algorithm().name() != OPENAPI_CLASSIFICATION_ALGORITHM
926            || derivation.algorithm().version() != 1
927            || derivation.parents() != expected_parents.into_iter().collect::<Vec<_>>()
928            || lineage.reliability() != document.reliability()
929            || openapi_receipt_fact(
930                receipt,
931                &HttpEvidencePredicate::RESPONSE_BODY_BYTES_OBSERVED.into_knowledge(),
932            )?
933            .value()
934                != &EvidenceValue::Unsigned(leg.response_bytes)
935            || openapi_receipt_fact(
936                receipt,
937                &HttpEvidencePredicate::RESPONSE_BODY_TRUNCATED.into_knowledge(),
938            )?
939            .value()
940                != &EvidenceValue::Boolean(false)
941            || openapi_receipt_fact(receipt, &phase)?.value() != &EvidenceValue::Boolean(false)
942        {
943            return Err(OpenApiRuntimeInvariantError::Catalog);
944        }
945        committed.insert(receipt.stage());
946    }
947    Ok(state.legs.keys().all(|stage| committed.contains(stage))
948        && (state.terminal.is_none() || terminal_committed))
949}
950
951#[cfg(feature = "secret-exposure-review")]
952fn openapi_receipt_fact<'a>(
953    receipt: &'a crate::DecisionEvidenceReceipt,
954    predicate: &KnowledgePredicate,
955) -> Result<&'a Evidence, OpenApiRuntimeInvariantError> {
956    let mut rows = receipt
957        .evidence()
958        .iter()
959        .filter(|item| item.predicate() == predicate);
960    let item = rows.next().ok_or(OpenApiRuntimeInvariantError::Catalog)?;
961    if rows.next().is_some() {
962        return Err(OpenApiRuntimeInvariantError::Catalog);
963    }
964    Ok(item)
965}
966
967fn openapi_transport_prefix_is_valid(receipts: &[&crate::TransportDispatchReceipt]) -> bool {
968    receipts.len() <= MAX_OPENAPI_REVIEW_REQUESTS
969        && receipts.iter().enumerate().all(|(index, receipt)| {
970            let expected_stage = if index == 0 {
971                DecisionExecutionStage::Passive
972            } else {
973                DecisionExecutionStage::Active
974            };
975            let expected_origin = (expected_stage == DecisionExecutionStage::Passive)
976                .then_some(DecisionActionOrigin::Planned);
977            receipt.stage() == expected_stage
978                && receipt.origin() == expected_origin
979                && receipt.request_body_bytes() == 0
980        })
981}
982
983fn captured_openapi_prefix_reconciles(
984    state: &OpenApiExecutionState,
985    receipts: &[&crate::TransportDispatchReceipt],
986) -> bool {
987    state.legs.iter().all(|(stage, leg)| {
988        let index = match stage {
989            DecisionExecutionStage::Passive => 0,
990            DecisionExecutionStage::Active => 1,
991        };
992        receipts.get(index).is_some_and(|receipt| {
993            receipt.outcome() == TransportDispatchOutcome::Completed
994                && receipt.response_bytes() == leg.response_bytes
995        })
996    })
997}
998
999fn commit_api_reasoning_inputs(
1000    knowledge: &KnowledgeBase,
1001    subject: &EntityId,
1002) -> Result<(), OpenApiRuntimeInvariantError> {
1003    let mut rules = RuleEngine::new();
1004    StandardApiReasoning::new()
1005        .and_then(|profile| profile.install(knowledge, &mut rules).map(|_| ()))
1006        .map_err(|_| OpenApiRuntimeInvariantError::Catalog)?;
1007    rules
1008        .apply(knowledge, subject)
1009        .map_err(|_| OpenApiRuntimeInvariantError::Catalog)?;
1010    Ok(())
1011}
1012
1013fn audit(
1014    source: OpenApiCandidateSource,
1015    outcome: OpenApiRuntimeOutcome,
1016    requests: u8,
1017    leg: Option<&OpenApiLeg>,
1018    projected: bool,
1019) -> WebAssessmentOpenApiAudit {
1020    WebAssessmentOpenApiAudit {
1021        outcome,
1022        candidate_source: source,
1023        request_count: requests,
1024        active_verification_count: u8::from(requests >= 2),
1025        version: leg.and_then(|l| l.version),
1026        semantic_digest: leg.map(|l| l.digest.clone()),
1027        path_count: leg.map_or(0, |l| l.paths),
1028        operation_count: leg.map_or(0, |l| l.operations),
1029        get_operation_count: leg.map_or(0, |l| l.get_operations),
1030        write_operation_count: leg.map_or(0, |l| l.write_operations),
1031        path_parameter_count: leg.map_or(0, |l| l.path_parameters),
1032        query_parameter_count: leg.map_or(0, |l| l.query_parameters),
1033        explicit_auth_operation_count: leg.map_or(0, |l| l.explicit_auth),
1034        anonymous_operation_count: leg.map_or(0, |l| l.anonymous),
1035        url_like_operation_count: leg.map_or(0, |l| l.url_like),
1036        multipart_operation_count: leg.map_or(0, |l| l.multipart),
1037        deprecated_operation_count: leg.map_or(0, |l| l.deprecated),
1038        replay_matched: projected,
1039        item_projected: projected,
1040    }
1041}
1042
1043#[derive(Debug, thiserror::Error)]
1044pub(super) enum OpenApiRuntimeInvariantError {
1045    #[error("OpenAPI review catalog invariant failed")]
1046    Catalog,
1047    #[error("OpenAPI evidence identity failed")]
1048    Evidence(#[from] termivar_core::ReasoningModelError),
1049}
1050pub(super) enum OpenApiRuntimeResult {
1051    Complete(CommittedOpenApiReview),
1052    Stopped {
1053        audit: WebAssessmentOpenApiAudit,
1054        runtime_limit: Option<RuntimeLimitExceeded>,
1055    },
1056}
1057pub(super) struct CommittedOpenApiReview {
1058    subject: EntityId,
1059    privacy: OpenApiResponsePrivacy,
1060    target_identity: String,
1061    outcome: OpenApiRuntimeOutcome,
1062    evidence_ids: Vec<EvidenceId>,
1063    audit: WebAssessmentOpenApiAudit,
1064}
1065impl CommittedOpenApiReview {
1066    pub(super) const fn audit(&self) -> &WebAssessmentOpenApiAudit {
1067        &self.audit
1068    }
1069}
1070
1071#[derive(Clone)]
1072struct OpenApiLeg {
1073    digest: String,
1074    version: Option<&'static str>,
1075    paths: u32,
1076    operations: u32,
1077    get_operations: u32,
1078    write_operations: u32,
1079    path_parameters: u32,
1080    query_parameters: u32,
1081    explicit_auth: u32,
1082    anonymous: u32,
1083    url_like: u32,
1084    multipart: u32,
1085    deprecated: u32,
1086    response_bytes: u64,
1087    evidence_id: Option<EvidenceId>,
1088    #[cfg(feature = "rest-review")]
1089    rest_selection: Option<RestOperationSelection>,
1090    #[cfg(all(feature = "ssrf-oast-review", feature = "openapi-review"))]
1091    ssrf_selection: Option<SsrfOastQueryCandidate>,
1092}
1093impl OpenApiLeg {
1094    fn semantically_matches(&self, other: &Self) -> bool {
1095        self.digest == other.digest
1096            && self.version == other.version
1097            && self.paths == other.paths
1098            && self.operations == other.operations
1099            && self.get_operations == other.get_operations
1100            && self.write_operations == other.write_operations
1101            && self.path_parameters == other.path_parameters
1102            && self.query_parameters == other.query_parameters
1103            && self.explicit_auth == other.explicit_auth
1104            && self.anonymous == other.anonymous
1105            && self.url_like == other.url_like
1106            && self.multipart == other.multipart
1107            && self.deprecated == other.deprecated
1108            && {
1109                #[cfg(feature = "rest-review")]
1110                {
1111                    self.rest_selection == other.rest_selection
1112                }
1113                #[cfg(not(feature = "rest-review"))]
1114                {
1115                    true
1116                }
1117            }
1118            && {
1119                #[cfg(all(feature = "ssrf-oast-review", feature = "openapi-review"))]
1120                {
1121                    self.ssrf_selection == other.ssrf_selection
1122                }
1123                #[cfg(not(all(feature = "ssrf-oast-review", feature = "openapi-review")))]
1124                {
1125                    true
1126                }
1127            }
1128    }
1129}
1130#[derive(Default)]
1131struct OpenApiExecutionState {
1132    legs: BTreeMap<DecisionExecutionStage, OpenApiLeg>,
1133    terminal: Option<(OpenApiRuntimeOutcome, Option<RuntimeLimitExceeded>)>,
1134}
1135struct OpenApiDecisionExecutor {
1136    requests: HttpRequestBroker,
1137    privacy: OpenApiResponsePrivacy,
1138    candidate: Mutex<OpenApiCandidate>,
1139    subject: EntityId,
1140    knowledge: KnowledgeBase,
1141    state: Mutex<OpenApiExecutionState>,
1142    #[cfg(feature = "rest-review")]
1143    rest_selection: StableRestSelectionSlot,
1144    #[cfg(all(feature = "ssrf-oast-review", feature = "openapi-review"))]
1145    ssrf_selection: Option<StableSsrfOastSelectionSlot>,
1146}
1147
1148impl OpenApiDecisionExecutor {
1149    fn take_state(&self) -> Result<OpenApiExecutionState, OpenApiRuntimeInvariantError> {
1150        Ok(std::mem::take(
1151            &mut *self
1152                .state
1153                .lock()
1154                .map_err(|_| OpenApiRuntimeInvariantError::Catalog)?,
1155        ))
1156    }
1157    fn stop(
1158        &self,
1159        outcome: OpenApiRuntimeOutcome,
1160        limit: Option<RuntimeLimitExceeded>,
1161    ) -> Result<(), DecisionExecutorError> {
1162        let mut state = self
1163            .state
1164            .lock()
1165            .map_err(|_| DecisionExecutorError::new("OpenAPI review state is unavailable"))?;
1166        if state.terminal.replace((outcome, limit)).is_some() {
1167            return Err(DecisionExecutorError::new(
1168                "OpenAPI review terminal state is duplicated",
1169            ));
1170        }
1171        Ok(())
1172    }
1173}
1174
1175#[async_trait]
1176impl DecisionActionExecutor for OpenApiDecisionExecutor {
1177    fn id(&self) -> &str {
1178        OPENAPI_EXECUTOR_ID
1179    }
1180    async fn execute(
1181        &self,
1182        request: &DecisionExecutionRequest,
1183    ) -> Result<Vec<Evidence>, DecisionExecutorError> {
1184        if request.case().action_id() != OPENAPI_REVIEW_ACTION_ID
1185            || request.case().subject() != &self.subject
1186            || request.case().payload_strategy().is_some()
1187            || request.case().applies_hypothesis_transition()
1188            || !matches!(
1189                request.stage(),
1190                DecisionExecutionStage::Passive | DecisionExecutionStage::Active
1191            )
1192        {
1193            return Err(DecisionExecutorError::new(
1194                "OpenAPI executor route contract failed",
1195            ));
1196        }
1197        let candidate = {
1198            let mut selected = self
1199                .candidate
1200                .lock()
1201                .map_err(|_| DecisionExecutorError::new("OpenAPI candidate is unavailable"))?;
1202            if request.stage() == DecisionExecutionStage::Passive {
1203                let hints = committed_discovery_hints(&self.knowledge, &self.subject);
1204                *selected = select_openapi_candidate(&selected.url, hints).ok_or_else(|| {
1205                    DecisionExecutorError::new("OpenAPI candidate selection failed")
1206                })?;
1207            }
1208            selected.clone()
1209        };
1210        let probe = HttpProbe::new(candidate.url.clone(), HttpProbeMethod::Get)
1211            .and_then(|p| p.with_header("accept", OPENAPI_ACCEPT))
1212            .map_err(|_| DecisionExecutorError::new("OpenAPI request construction failed"))?;
1213        let response = match self
1214            .requests
1215            .collect_for_runtime(
1216                OPENAPI_REVIEW_ACTION_ID,
1217                request.stage(),
1218                request.origin(),
1219                request.limits(),
1220                &probe,
1221            )
1222            .await
1223        {
1224            Ok(r) => r,
1225            Err(HttpRequestBrokerError::RuntimeLimit(limit)) => {
1226                self.stop(OpenApiRuntimeOutcome::BudgetExhausted, Some(limit))?;
1227                return phase_terminal_evidence(request);
1228            },
1229            Err(HttpRequestBrokerError::Http(_)) => {
1230                self.stop(OpenApiRuntimeOutcome::Incomplete, None)?;
1231                return phase_terminal_evidence(request);
1232            },
1233        };
1234        let status = response.status();
1235        if response.final_url() != &candidate.url {
1236            self.stop(OpenApiRuntimeOutcome::RedirectObserved, None)?;
1237            return transport_evidence(request, &response, None, true, self.privacy);
1238        }
1239        let defense_signal = response.openapi_defense_signal();
1240        let defensive = defense_signal.state().is_challenged();
1241        let rate_limited = defense_signal.state().is_rate_limited();
1242        let media = response.normalized_media_type();
1243        let media_allowed =
1244            response.has_json_compatible_media_type() || media.as_deref() == Some("text/plain");
1245        let terminal = if response.body_truncated() {
1246            Some(OpenApiRuntimeOutcome::Truncated)
1247        } else if !response.body_complete() {
1248            Some(OpenApiRuntimeOutcome::Incomplete)
1249        } else if rate_limited {
1250            Some(OpenApiRuntimeOutcome::RateLimited)
1251        } else if defensive {
1252            Some(OpenApiRuntimeOutcome::DefensiveInterference)
1253        } else if (300..400).contains(&status) {
1254            Some(OpenApiRuntimeOutcome::RedirectObserved)
1255        } else if !(200..300).contains(&status) {
1256            Some(OpenApiRuntimeOutcome::HttpError)
1257        } else if !media_allowed {
1258            Some(OpenApiRuntimeOutcome::UnsupportedMedia)
1259        } else {
1260            None
1261        };
1262        if let Some(outcome) = terminal {
1263            self.stop(outcome, None)?;
1264            return transport_evidence(request, &response, None, true, self.privacy);
1265        }
1266        let parsed = parse_openapi_document(response.body(), &candidate.url);
1267        let leg = match parsed {
1268            OpenApiParseOutcome::Complete(doc) => {
1269                #[cfg(feature = "rest-review")]
1270                let rest_selection = match select_rest_operation(&doc, &candidate.url) {
1271                    RestOperationSelectionOutcome::Selected(selection) => Some(selection),
1272                    RestOperationSelectionOutcome::NoEligibleOperation => None,
1273                };
1274                #[cfg(all(feature = "ssrf-oast-review", feature = "openapi-review"))]
1275                let ssrf_selection = select_openapi_document_query_candidate(&doc, &candidate.url);
1276                let summary = doc.catalog().summary();
1277                let get = doc
1278                    .catalog()
1279                    .operations()
1280                    .iter()
1281                    .filter(|op| op.method() == OpenApiHttpMethod::Get)
1282                    .count();
1283                let write = doc
1284                    .catalog()
1285                    .operations()
1286                    .iter()
1287                    .filter(|op| {
1288                        matches!(
1289                            op.method(),
1290                            OpenApiHttpMethod::Post
1291                                | OpenApiHttpMethod::Put
1292                                | OpenApiHttpMethod::Patch
1293                                | OpenApiHttpMethod::Delete
1294                        )
1295                    })
1296                    .count();
1297                let deprecated = doc
1298                    .catalog()
1299                    .operations()
1300                    .iter()
1301                    .filter(|op| op.deprecated())
1302                    .count();
1303                OpenApiLeg {
1304                    digest: doc.semantic_digest().to_owned(),
1305                    version: doc.version().map(version_name),
1306                    paths: u32::try_from(doc.path_count()).unwrap_or(u32::MAX),
1307                    operations: u32::try_from(doc.operation_count()).unwrap_or(u32::MAX),
1308                    get_operations: u32::try_from(get).unwrap_or(u32::MAX),
1309                    write_operations: u32::try_from(write).unwrap_or(u32::MAX),
1310                    path_parameters: u32::try_from(summary.path_parameter_count)
1311                        .unwrap_or(u32::MAX),
1312                    query_parameters: u32::try_from(summary.query_parameter_count)
1313                        .unwrap_or(u32::MAX),
1314                    explicit_auth: u32::try_from(summary.explicit_auth_operation_count)
1315                        .unwrap_or(u32::MAX),
1316                    anonymous: u32::try_from(summary.anonymous_operation_count).unwrap_or(u32::MAX),
1317                    url_like: u32::try_from(doc.catalog().with_url_like_input().len())
1318                        .unwrap_or(u32::MAX),
1319                    multipart: u32::try_from(summary.multipart_operation_count).unwrap_or(u32::MAX),
1320                    deprecated: u32::try_from(deprecated).unwrap_or(u32::MAX),
1321                    response_bytes: u64::try_from(response.body().len()).unwrap_or(u64::MAX),
1322                    evidence_id: None,
1323                    #[cfg(feature = "rest-review")]
1324                    rest_selection,
1325                    #[cfg(all(feature = "ssrf-oast-review", feature = "openapi-review"))]
1326                    ssrf_selection,
1327                }
1328            },
1329            OpenApiParseOutcome::Swagger20MetadataOnly => {
1330                self.stop(OpenApiRuntimeOutcome::Swagger20MetadataOnly, None)?;
1331                return transport_evidence(request, &response, None, true, self.privacy);
1332            },
1333            OpenApiParseOutcome::UnsupportedVersion => {
1334                self.stop(OpenApiRuntimeOutcome::UnsupportedVersion, None)?;
1335                return transport_evidence(request, &response, None, true, self.privacy);
1336            },
1337            OpenApiParseOutcome::Malformed => {
1338                self.stop(OpenApiRuntimeOutcome::Malformed, None)?;
1339                return transport_evidence(request, &response, None, true, self.privacy);
1340            },
1341            OpenApiParseOutcome::LimitExceeded => {
1342                self.stop(OpenApiRuntimeOutcome::LimitExceeded, None)?;
1343                return transport_evidence(request, &response, None, true, self.privacy);
1344            },
1345            OpenApiParseOutcome::TooLarge => {
1346                self.stop(OpenApiRuntimeOutcome::TooLarge, None)?;
1347                return transport_evidence(request, &response, None, true, self.privacy);
1348            },
1349        };
1350        #[cfg(feature = "rest-review")]
1351        let stable_rest_catalog = if request.stage() == DecisionExecutionStage::Active {
1352            let state = self
1353                .state
1354                .lock()
1355                .map_err(|_| DecisionExecutorError::new("OpenAPI review state is unavailable"))?;
1356            state
1357                .legs
1358                .get(&DecisionExecutionStage::Passive)
1359                .is_some_and(|candidate_leg| candidate_leg.semantically_matches(&leg))
1360        } else {
1361            false
1362        };
1363        #[cfg(feature = "rest-review")]
1364        let stable_rest_selection = stable_rest_catalog
1365            .then(|| leg.rest_selection.clone())
1366            .flatten();
1367        #[cfg(all(feature = "ssrf-oast-review", feature = "openapi-review"))]
1368        let stable_ssrf_selection = if request.stage() == DecisionExecutionStage::Active {
1369            let state = self
1370                .state
1371                .lock()
1372                .map_err(|_| DecisionExecutorError::new("OpenAPI review state is unavailable"))?;
1373            state
1374                .legs
1375                .get(&DecisionExecutionStage::Passive)
1376                .filter(|candidate_leg| candidate_leg.semantically_matches(&leg))
1377                .and_then(|_| leg.ssrf_selection.clone())
1378        } else {
1379            None
1380        };
1381        let mut evidence = transport_evidence(request, &response, Some(&leg), false, self.privacy)?;
1382        #[cfg(feature = "rest-review")]
1383        if stable_rest_catalog {
1384            let ready = make_evidence(
1385                request,
1386                crate::web_actions::rest_review_catalog_ready_predicate(),
1387                EvidenceValue::Boolean(true),
1388                "rest-catalog-ready",
1389            )?;
1390            let first_defense = evidence
1391                .iter()
1392                .position(|item| {
1393                    item.predicate().namespace()
1394                        == super::assessment_defense::ASSESSMENT_DEFENSE_NAMESPACE
1395                })
1396                .unwrap_or(evidence.len());
1397            evidence.insert(first_defense, ready);
1398        }
1399        let classification = evidence
1400            .iter()
1401            .find(|e| {
1402                e.predicate().namespace() == OPENAPI_EVIDENCE_NAMESPACE
1403                    && e.predicate().name() == "document"
1404            })
1405            .ok_or_else(|| DecisionExecutorError::new("OpenAPI evidence is missing"))?
1406            .id()
1407            .clone();
1408        let mut stored = leg;
1409        stored.evidence_id = Some(classification);
1410        let replaced = self
1411            .state
1412            .lock()
1413            .map_err(|_| DecisionExecutorError::new("OpenAPI review state is unavailable"))?
1414            .legs
1415            .insert(request.stage(), stored);
1416        if replaced.is_some() {
1417            return Err(DecisionExecutorError::new(
1418                "OpenAPI review leg is duplicated",
1419            ));
1420        }
1421        #[cfg(feature = "rest-review")]
1422        if let Some(selection) = stable_rest_selection {
1423            self.rest_selection
1424                .commit(selection)
1425                .map_err(|_| DecisionExecutorError::new("REST selection handoff failed"))?;
1426        }
1427        #[cfg(all(feature = "ssrf-oast-review", feature = "openapi-review"))]
1428        if let (Some(slot), Some(selection)) = (&self.ssrf_selection, stable_ssrf_selection) {
1429            slot.commit_openapi(SsrfOastCandidateSelection::Selected(Box::new(selection)))
1430                .map_err(|_| DecisionExecutorError::new("SSRF OAST selection handoff failed"))?;
1431        }
1432        Ok(evidence)
1433    }
1434}
1435
1436fn version_name(version: OpenApiVersion) -> &'static str {
1437    match version {
1438        OpenApiVersion::OpenApi30 => "3.0",
1439        OpenApiVersion::OpenApi31 => "3.1",
1440    }
1441}
1442fn marker(request: &DecisionExecutionRequest) -> Result<Evidence, DecisionExecutorError> {
1443    make_evidence(
1444        request,
1445        crate::web_actions::native_web_review_response_marker_predicate(),
1446        EvidenceValue::Boolean(true),
1447        "complete",
1448    )
1449}
1450fn phase_terminal_evidence(
1451    request: &DecisionExecutionRequest,
1452) -> Result<Vec<Evidence>, DecisionExecutorError> {
1453    Ok(vec![
1454        make_evidence(
1455            request,
1456            HttpEvidencePredicate::RESPONSE_BODY_BYTES_OBSERVED.into_knowledge(),
1457            EvidenceValue::Unsigned(0),
1458            "response-body-size",
1459        )?,
1460        make_evidence(
1461            request,
1462            crate::web_actions::openapi_review_phase_terminal_predicate(),
1463            EvidenceValue::Boolean(true),
1464            "phase-terminal",
1465        )?,
1466    ])
1467}
1468fn make_evidence(
1469    request: &DecisionExecutionRequest,
1470    predicate: KnowledgePredicate,
1471    value: EvidenceValue,
1472    method: &str,
1473) -> Result<Evidence, DecisionExecutorError> {
1474    Ok(Evidence::new(
1475        request.case().subject().clone(),
1476        EvidenceKind::Custom("openapi-review".into()),
1477        predicate,
1478        value,
1479        EvidenceSource::new(OPENAPI_EXECUTOR_ID, method)
1480            .and_then(|s| s.with_correlation_id(request.case().id()))
1481            .map_err(|_| DecisionExecutorError::new("OpenAPI evidence source failed"))?,
1482        ConfidenceScore::MAX,
1483    ))
1484}
1485
1486fn make_typed_evidence(
1487    request: &DecisionExecutionRequest,
1488    kind: EvidenceKind,
1489    predicate: KnowledgePredicate,
1490    value: EvidenceValue,
1491    method: &str,
1492) -> Result<Evidence, DecisionExecutorError> {
1493    Ok(Evidence::new(
1494        request.case().subject().clone(),
1495        kind,
1496        predicate,
1497        value,
1498        EvidenceSource::new(OPENAPI_EXECUTOR_ID, method)
1499            .and_then(|s| s.with_correlation_id(request.case().id()))
1500            .map_err(|_| DecisionExecutorError::new("OpenAPI evidence source failed"))?,
1501        ConfidenceScore::MAX,
1502    ))
1503}
1504
1505fn transport_evidence(
1506    request: &DecisionExecutionRequest,
1507    response: &crate::http_evidence::CollectedHttpResponse,
1508    leg: Option<&OpenApiLeg>,
1509    terminal: bool,
1510    privacy: OpenApiResponsePrivacy,
1511) -> Result<Vec<Evidence>, DecisionExecutorError> {
1512    let signal = response.openapi_defense_signal();
1513    let mut evidence = vec![
1514        make_typed_evidence(
1515            request,
1516            EvidenceKind::Http,
1517            HttpEvidencePredicate::REQUEST_METHOD.into_knowledge(),
1518            EvidenceValue::Text("GET".into()),
1519            "request-method",
1520        )?,
1521        make_typed_evidence(
1522            request,
1523            EvidenceKind::Http,
1524            HttpEvidencePredicate::REQUEST_URL.into_knowledge(),
1525            EvidenceValue::Text(response.final_url().to_string()),
1526            "request-url",
1527        )?,
1528        make_typed_evidence(
1529            request,
1530            EvidenceKind::Http,
1531            HttpEvidencePredicate::RESPONSE_STATUS.into_knowledge(),
1532            EvidenceValue::Unsigned(u64::from(response.status())),
1533            "response-status",
1534        )?,
1535        make_typed_evidence(
1536            request,
1537            EvidenceKind::Http,
1538            HttpEvidencePredicate::RESPONSE_FINAL_URL.into_knowledge(),
1539            EvidenceValue::Text(response.final_url().to_string()),
1540            "response-final-url",
1541        )?,
1542        make_typed_evidence(
1543            request,
1544            EvidenceKind::Content,
1545            HttpEvidencePredicate::RESPONSE_BODY_BYTES_OBSERVED.into_knowledge(),
1546            EvidenceValue::Unsigned(u64::try_from(response.body().len()).unwrap_or(u64::MAX)),
1547            "response-body-size",
1548        )?,
1549        make_typed_evidence(
1550            request,
1551            EvidenceKind::Content,
1552            HttpEvidencePredicate::RESPONSE_BODY_TRUNCATED.into_knowledge(),
1553            EvidenceValue::Boolean(response.body_truncated()),
1554            "response-body-truncation",
1555        )?,
1556        make_typed_evidence(
1557            request,
1558            EvidenceKind::RateLimit,
1559            HttpEvidencePredicate::RATE_LIMIT_DETECTED.into_knowledge(),
1560            EvidenceValue::Boolean(response.status() == 429),
1561            "rate-limit-status",
1562        )?,
1563        make_typed_evidence(
1564            request,
1565            EvidenceKind::RateLimit,
1566            HttpEvidencePredicate::RATE_LIMIT_ADVERTISED.into_knowledge(),
1567            EvidenceValue::Boolean(signal.state().has_rate_limit_headers()),
1568            "rate-limit-headers",
1569        )?,
1570        make_evidence(
1571            request,
1572            crate::web_actions::openapi_review_phase_terminal_predicate(),
1573            EvidenceValue::Boolean(terminal),
1574            "phase-terminal",
1575        )?,
1576    ];
1577    if !privacy.is_value_free() {
1578        // Preserve historical evidence ordering and values when S04 is absent.
1579        evidence.insert(
1580            6,
1581            make_typed_evidence(
1582                request,
1583                EvidenceKind::Content,
1584                HttpEvidencePredicate::RESPONSE_BODY_SHA256.into_knowledge(),
1585                EvidenceValue::Text(format!("{:x}", Sha256::digest(response.body()))),
1586                "response-body-sha256",
1587            )?,
1588        );
1589    }
1590    if let Some(media) = response.normalized_media_type() {
1591        evidence.push(make_typed_evidence(
1592            request,
1593            EvidenceKind::Http,
1594            HttpEvidencePredicate::RESPONSE_MEDIA_TYPE.into_knowledge(),
1595            EvidenceValue::Text(media.clone()),
1596            "response-media-type",
1597        )?);
1598        evidence.push(make_typed_evidence(
1599            request,
1600            EvidenceKind::Http,
1601            HttpEvidencePredicate::RESPONSE_MEDIA_TYPE_JSON_COMPATIBLE.into_knowledge(),
1602            EvidenceValue::Boolean(response.has_json_compatible_media_type()),
1603            "response-media-type-json-compatibility",
1604        )?);
1605    }
1606    if privacy.is_value_free() {
1607        #[cfg(feature = "secret-exposure-review")]
1608        evidence.push(
1609            super::secret_exposure::project_openapi_body_privacy_lineage(
1610                request,
1611                &evidence,
1612                OPENAPI_EXECUTOR_ID,
1613                ConfidenceScore::MAX,
1614            )
1615            .map_err(|_| DecisionExecutorError::new("OpenAPI privacy lineage failed"))?,
1616        );
1617        #[cfg(not(feature = "secret-exposure-review"))]
1618        return Err(DecisionExecutorError::new(
1619            "OpenAPI privacy selection is unavailable",
1620        ));
1621    }
1622    // Defense consumes only the fixed response facts plus exactly one body
1623    // identity. Optional media and semantic facts are not additional parents.
1624    let base_predicates = [
1625        HttpEvidencePredicate::REQUEST_METHOD,
1626        HttpEvidencePredicate::REQUEST_URL,
1627        HttpEvidencePredicate::RESPONSE_STATUS,
1628        HttpEvidencePredicate::RESPONSE_FINAL_URL,
1629        HttpEvidencePredicate::RESPONSE_BODY_BYTES_OBSERVED,
1630        HttpEvidencePredicate::RESPONSE_BODY_TRUNCATED,
1631        HttpEvidencePredicate::RESPONSE_BODY_SHA256,
1632        HttpEvidencePredicate::RATE_LIMIT_DETECTED,
1633        HttpEvidencePredicate::RATE_LIMIT_ADVERTISED,
1634    ]
1635    .map(|predicate| predicate.into_knowledge());
1636    let defense_parents = evidence
1637        .iter()
1638        .filter(|item| {
1639            let lineage = false;
1640            #[cfg(feature = "secret-exposure-review")]
1641            let lineage = lineage
1642                || (item.predicate().namespace()
1643                    == super::secret_exposure::SECRET_EXPOSURE_NAMESPACE
1644                    && item.predicate().name()
1645                        == super::secret_exposure::ACTIVE_REVIEW_BODY_LINEAGE);
1646            base_predicates.contains(item.predicate()) || lineage
1647        })
1648        .map(|item| item.id().clone())
1649        .collect();
1650    if let Some(leg) = leg {
1651        let mut summary = vec![
1652            format!("version={}", leg.version.unwrap_or("unknown")),
1653            format!("paths={}", leg.paths),
1654            format!("operations={}", leg.operations),
1655        ];
1656        if !privacy.is_value_free() {
1657            summary.insert(0, format!("digest={}", leg.digest));
1658        }
1659        let document = make_evidence(
1660            request,
1661            KnowledgePredicate::new(OPENAPI_EVIDENCE_NAMESPACE, "document")
1662                .map_err(|_| DecisionExecutorError::new("OpenAPI predicate failed"))?,
1663            EvidenceValue::TextList(summary),
1664            "document",
1665        )?;
1666        let document = if privacy.is_value_free() {
1667            document.derived_from(
1668                EvidenceDerivation::new(
1669                    evidence.iter().map(|item| item.id().clone()),
1670                    DerivationAlgorithm::new(OPENAPI_CLASSIFICATION_ALGORITHM, 1).map_err(
1671                        |_| DecisionExecutorError::new("OpenAPI classification algorithm failed"),
1672                    )?,
1673                )
1674                .map_err(|_| DecisionExecutorError::new("OpenAPI classification lineage failed"))?,
1675            )
1676        } else {
1677            document
1678        };
1679        evidence.push(document);
1680    }
1681    if !terminal {
1682        evidence.push(marker(request)?);
1683    }
1684    evidence.extend(
1685        project_assessment_defense_signal(
1686            &signal,
1687            AssessmentDefenseProjectionContext {
1688                subject: request.case().subject(),
1689                case_id: request.case().id(),
1690                executor_id: OPENAPI_EXECUTOR_ID,
1691                reliability: ConfidenceScore::MAX,
1692                parents: defense_parents,
1693            },
1694        )
1695        .map_err(|_| DecisionExecutorError::new("OpenAPI defense projection failed"))?,
1696    );
1697    if terminal { /* terminal response intentionally omits completion marker */ }
1698    Ok(evidence)
1699}
1700
1701pub(super) fn project_openapi_item(
1702    context: &mut AssessmentProjectionContext,
1703    knowledge: &KnowledgeBase,
1704    review: &CommittedOpenApiReview,
1705) -> Result<(), AssessmentItemProjectionError> {
1706    for id in &review.evidence_ids {
1707        context.register_evidence(knowledge, id)?;
1708    }
1709    if review.outcome == OpenApiRuntimeOutcome::DocumentObserved {
1710        let target = if review.privacy.is_value_free() {
1711            AssessmentItemTarget::Subject
1712        } else {
1713            AssessmentItemTarget::openapi_document(review.target_identity.clone())?
1714        };
1715        context.project_observation(
1716            &OPENAPI_CAPABILITY,
1717            knowledge,
1718            &review.subject,
1719            &target,
1720            &review.evidence_ids,
1721        )?;
1722    }
1723    Ok(())
1724}
1725
1726#[cfg(test)]
1727mod tests {
1728    #[cfg(feature = "secret-exposure-review")]
1729    mod privacy_tests {
1730        //! Independent synthetic receipt oracles. No HTTP response or wire trace is
1731        //! claimed: the ordinary runner commits literal batches, and the accounting
1732        //! broker supplies a test-owned lease for the received-response cases.
1733
1734        use super::*;
1735        use crate::{
1736            runtime_budget::RequestAccountingBroker, DecisionEvidenceReceipt, DecisionLoopCommand,
1737            DecisionRunnerAdapter, DecisionStopReason, KnowledgeWrite, RuntimeBudget, RuntimeUsage,
1738            StandardWebDecisionRunReport, VerificationCase,
1739        };
1740
1741        const CASE: &str = "case:synthetic-openapi-privacy";
1742        const RESOURCE: &str = "https://fixture.invalid/openapi.json";
1743
1744        struct LiteralExecutor {
1745            id: &'static str,
1746            batch: Vec<Evidence>,
1747        }
1748
1749        #[async_trait]
1750        impl DecisionActionExecutor for LiteralExecutor {
1751            fn id(&self) -> &str {
1752                self.id
1753            }
1754
1755            async fn execute(
1756                &self,
1757                _request: &DecisionExecutionRequest,
1758            ) -> Result<Vec<Evidence>, DecisionExecutorError> {
1759                assert!(self.batch.len() <= 32);
1760                Ok(self.batch.clone())
1761            }
1762        }
1763
1764        fn subject() -> EntityId {
1765            EntityId::new("endpoint:https://fixture.invalid/").unwrap()
1766        }
1767
1768        fn candidate() -> OpenApiCandidate {
1769            OpenApiCandidate {
1770                url: Url::parse(RESOURCE).unwrap(),
1771                source: OpenApiCandidateSource::ConventionalOpenApiJson,
1772                identity: "synthetic-url-identity".to_owned(),
1773            }
1774        }
1775
1776        fn fact(
1777            kind: EvidenceKind,
1778            predicate: KnowledgePredicate,
1779            value: EvidenceValue,
1780            method: &str,
1781        ) -> Evidence {
1782            Evidence::new(
1783                subject(),
1784                kind,
1785                predicate,
1786                value,
1787                EvidenceSource::new("http.openapi-review", method)
1788                    .unwrap()
1789                    .with_correlation_id(CASE)
1790                    .unwrap(),
1791                ConfidenceScore::MAX,
1792            )
1793        }
1794
1795        fn custom(name: &str, value: EvidenceValue, method: &str) -> Evidence {
1796            fact(
1797                EvidenceKind::Custom("openapi-review".to_owned()),
1798                KnowledgePredicate::new("web.openapi-review.transport", name).unwrap(),
1799                value,
1800                method,
1801            )
1802        }
1803
1804        fn terminal_batch() -> Vec<Evidence> {
1805            vec![
1806                fact(
1807                    EvidenceKind::Custom("openapi-review".to_owned()),
1808                    HttpEvidencePredicate::RESPONSE_BODY_BYTES_OBSERVED.into_knowledge(),
1809                    EvidenceValue::Unsigned(0),
1810                    "response-body-size",
1811                ),
1812                fact(
1813                    EvidenceKind::Custom("openapi-review".to_owned()),
1814                    crate::web_actions::openapi_review_phase_terminal_predicate(),
1815                    EvidenceValue::Boolean(true),
1816                    "phase-terminal",
1817                ),
1818            ]
1819        }
1820
1821        fn received_batch(parsed: bool) -> Vec<Evidence> {
1822            let rows = [
1823                (
1824                    HttpEvidencePredicate::REQUEST_METHOD,
1825                    EvidenceKind::Http,
1826                    EvidenceValue::Text("GET".to_owned()),
1827                    "request-method",
1828                ),
1829                (
1830                    HttpEvidencePredicate::REQUEST_URL,
1831                    EvidenceKind::Http,
1832                    EvidenceValue::Text(RESOURCE.to_owned()),
1833                    "request-url",
1834                ),
1835                (
1836                    HttpEvidencePredicate::RESPONSE_STATUS,
1837                    EvidenceKind::Http,
1838                    EvidenceValue::Unsigned(if parsed { 200 } else { 404 }),
1839                    "response-status",
1840                ),
1841                (
1842                    HttpEvidencePredicate::RESPONSE_FINAL_URL,
1843                    EvidenceKind::Http,
1844                    EvidenceValue::Text(RESOURCE.to_owned()),
1845                    "response-final-url",
1846                ),
1847                (
1848                    HttpEvidencePredicate::RESPONSE_BODY_BYTES_OBSERVED,
1849                    EvidenceKind::Content,
1850                    EvidenceValue::Unsigned(7),
1851                    "response-body-size",
1852                ),
1853                (
1854                    HttpEvidencePredicate::RESPONSE_BODY_TRUNCATED,
1855                    EvidenceKind::Content,
1856                    EvidenceValue::Boolean(false),
1857                    "response-body-truncation",
1858                ),
1859            ];
1860            let mut batch = rows
1861                .into_iter()
1862                .map(|(p, k, v, m)| fact(k, p.into_knowledge(), v, m))
1863                .collect::<Vec<_>>();
1864            let privacy_parents = batch
1865                .iter()
1866                .map(|item| item.id().clone())
1867                .collect::<Vec<_>>();
1868            for (predicate, method) in [
1869                (
1870                    HttpEvidencePredicate::RATE_LIMIT_DETECTED,
1871                    "rate-limit-status",
1872                ),
1873                (
1874                    HttpEvidencePredicate::RATE_LIMIT_ADVERTISED,
1875                    "rate-limit-headers",
1876                ),
1877            ] {
1878                batch.push(fact(
1879                    EvidenceKind::RateLimit,
1880                    predicate.into_knowledge(),
1881                    EvidenceValue::Boolean(false),
1882                    method,
1883                ));
1884            }
1885            batch.push(fact(
1886                EvidenceKind::Custom("openapi-review".to_owned()),
1887                crate::web_actions::openapi_review_phase_terminal_predicate(),
1888                EvidenceValue::Boolean(!parsed),
1889                "phase-terminal",
1890            ));
1891            batch.push(
1892                fact(
1893                    EvidenceKind::Custom("passive-secret-exposure".to_owned()),
1894                    KnowledgePredicate::new("web.secret-exposure", "active_review_body_lineage")
1895                        .unwrap(),
1896                    EvidenceValue::Boolean(true),
1897                    "active-review-body-digest-privacy-guard",
1898                )
1899                .derived_from(
1900                    EvidenceDerivation::new(
1901                        privacy_parents,
1902                        DerivationAlgorithm::new(
1903                            "web.secret-exposure.active-review-body-digest-privacy-guard",
1904                            1,
1905                        )
1906                        .unwrap(),
1907                    )
1908                    .unwrap(),
1909                ),
1910            );
1911            if parsed {
1912                let parents = batch
1913                    .iter()
1914                    .map(|item| item.id().clone())
1915                    .collect::<Vec<_>>();
1916                batch.push(
1917                    custom(
1918                        "document",
1919                        EvidenceValue::TextList(vec![
1920                            "version=3.1".to_owned(),
1921                            "paths=1".to_owned(),
1922                            "operations=1".to_owned(),
1923                        ]),
1924                        "document",
1925                    )
1926                    .derived_from(
1927                        EvidenceDerivation::new(
1928                            parents,
1929                            DerivationAlgorithm::new(
1930                                "web.openapi-review.value-free-classification",
1931                                1,
1932                            )
1933                            .unwrap(),
1934                        )
1935                        .unwrap(),
1936                    ),
1937                );
1938                batch.push(fact(
1939                    EvidenceKind::Custom("openapi-review".to_owned()),
1940                    crate::web_actions::native_web_review_response_marker_predicate(),
1941                    EvidenceValue::Boolean(true),
1942                    "complete",
1943                ));
1944            }
1945            batch
1946        }
1947
1948        async fn committed_route(
1949            batch: Vec<Evidence>,
1950            action: &str,
1951            executor: &'static str,
1952            stage: DecisionExecutionStage,
1953            transitioned: bool,
1954        ) -> (KnowledgeBase, DecisionEvidenceReceipt) {
1955            // Wrong-executor tests still pass the runner's own provenance check. The
1956            // receipt is genuinely committed for that other executor, then rejected
1957            // by the narrower OpenAPI consumer rather than failing in test setup.
1958            let batch = if executor == "http.openapi-review" {
1959                batch
1960            } else {
1961                batch
1962                    .into_iter()
1963                    .map(|item| {
1964                        let source = EvidenceSource::new(executor, item.source().method())
1965                            .unwrap()
1966                            .with_correlation_id(CASE)
1967                            .unwrap();
1968                        replace(
1969                            &item,
1970                            item.kind().clone(),
1971                            item.value().clone(),
1972                            source,
1973                            None,
1974                        )
1975                    })
1976                    .collect()
1977            };
1978            let mut registry = DecisionExecutorRegistry::new();
1979            registry
1980                .register(Arc::new(LiteralExecutor {
1981                    id: executor,
1982                    batch,
1983                }))
1984                .unwrap();
1985            registry.route_action(stage, action, executor).unwrap();
1986            let case =
1987                VerificationCase::new(CASE, subject(), action, "hypothesis:synthetic-openapi")
1988                    .unwrap();
1989            let case = if transitioned {
1990                case
1991            } else {
1992                case.without_hypothesis_transition()
1993            };
1994            let command = match stage {
1995                DecisionExecutionStage::Passive => DecisionLoopCommand::ExecuteAction {
1996                    case,
1997                    executor: None,
1998                    origin: DecisionActionOrigin::Planned,
1999                    delay_ms: None,
2000                },
2001                DecisionExecutionStage::Active => {
2002                    DecisionLoopCommand::CollectActiveEvidence { case }
2003                },
2004            };
2005            let knowledge = KnowledgeBase::new();
2006            let receipt = DecisionRunnerAdapter::new(registry)
2007                .execute_command(&command, &knowledge)
2008                .await
2009                .unwrap();
2010            (knowledge, receipt)
2011        }
2012
2013        async fn committed(batch: Vec<Evidence>) -> (KnowledgeBase, DecisionEvidenceReceipt) {
2014            committed_route(
2015                batch,
2016                "web.review.openapi.document-replay@1",
2017                "http.openapi-review",
2018                DecisionExecutionStage::Passive,
2019                false,
2020            )
2021            .await
2022        }
2023
2024        fn report(
2025            receipt: Option<DecisionEvidenceReceipt>,
2026            received: bool,
2027        ) -> StandardWebDecisionRunReport {
2028            let accounting = RequestAccountingBroker::new(RuntimeBudget::default());
2029            if received {
2030                let mut lease = accounting
2031                    .try_begin(
2032                        "web.review.openapi.document-replay@1",
2033                        DecisionExecutionStage::Passive,
2034                        Some(DecisionActionOrigin::Planned),
2035                    )
2036                    .unwrap();
2037                assert_eq!(lease.observe_response_bytes(7), 7);
2038                lease.finish(TransportDispatchOutcome::Completed);
2039            }
2040            StandardWebDecisionRunReport {
2041                bootstrap: None,
2042                turns: Vec::new(),
2043                unverified_evidence: receipt,
2044                terminal: DecisionLoopCommand::Halt {
2045                    reason: DecisionStopReason::NoEligibleAction,
2046                },
2047                usage: RuntimeUsage::default(),
2048                transport: accounting.dispatch_audit(),
2049                limit_exceeded: None,
2050                execution_failure: None,
2051            }
2052        }
2053
2054        fn terminal_state() -> OpenApiExecutionState {
2055            OpenApiExecutionState {
2056                legs: BTreeMap::new(),
2057                terminal: Some((OpenApiRuntimeOutcome::Incomplete, None)),
2058            }
2059        }
2060
2061        fn parsed_state(batch: &[Evidence]) -> OpenApiExecutionState {
2062            let document = batch
2063                .iter()
2064                .find(|item| {
2065                    item.predicate().namespace() == "web.openapi-review.transport"
2066                        && item.predicate().name() == "document"
2067                })
2068                .unwrap();
2069            let leg = OpenApiLeg {
2070                digest: "private-synthetic-catalogue-not-exported".to_owned(),
2071                version: Some("3.1"),
2072                paths: 1,
2073                operations: 1,
2074                get_operations: 1,
2075                write_operations: 0,
2076                path_parameters: 0,
2077                query_parameters: 0,
2078                explicit_auth: 0,
2079                anonymous: 1,
2080                url_like: 0,
2081                multipart: 0,
2082                deprecated: 0,
2083                response_bytes: 7,
2084                evidence_id: Some(document.id().clone()),
2085                #[cfg(feature = "rest-review")]
2086                rest_selection: None,
2087                #[cfg(feature = "ssrf-oast-review")]
2088                ssrf_selection: None,
2089            };
2090            OpenApiExecutionState {
2091                legs: BTreeMap::from([(DecisionExecutionStage::Passive, leg)]),
2092                terminal: None,
2093            }
2094        }
2095
2096        fn validate(
2097            receipt: Option<DecisionEvidenceReceipt>,
2098            knowledge: &KnowledgeBase,
2099            state: &OpenApiExecutionState,
2100            received: bool,
2101        ) -> Result<bool, OpenApiRuntimeInvariantError> {
2102            validate_value_free_openapi_commits(
2103                &report(receipt, received),
2104                knowledge,
2105                &subject(),
2106                &candidate(),
2107                state,
2108            )
2109        }
2110
2111        fn mutation(
2112            template: &DecisionEvidenceReceipt,
2113            batch: Vec<Evidence>,
2114        ) -> (KnowledgeBase, DecisionEvidenceReceipt) {
2115            let knowledge = KnowledgeBase::new();
2116            let writes = knowledge.insert_evidence_batch(batch.clone()).unwrap();
2117            let snapshot = knowledge.snapshot_for_subject(template.case().subject());
2118            (
2119                knowledge,
2120                template.with_test_committed_batch(batch, writes, snapshot),
2121            )
2122        }
2123
2124        fn replace(
2125            item: &Evidence,
2126            kind: EvidenceKind,
2127            value: EvidenceValue,
2128            source: EvidenceSource,
2129            parents: Option<Vec<EvidenceId>>,
2130        ) -> Evidence {
2131            let mut result = Evidence::with_id_at(
2132                item.id().clone(),
2133                item.subject().clone(),
2134                kind,
2135                item.predicate().clone(),
2136                value,
2137                source,
2138                item.reliability(),
2139                item.observed_at_ms(),
2140            );
2141            if let Some(original) = item.origin().derivation() {
2142                result = result.derived_from(
2143                    EvidenceDerivation::new(
2144                        parents.unwrap_or_else(|| original.parents().to_vec()),
2145                        original.algorithm().clone(),
2146                    )
2147                    .unwrap(),
2148                );
2149            }
2150            result
2151        }
2152
2153        #[tokio::test]
2154        async fn openapi_privacy_commit_requires_receipts_not_staged_terminal_or_leg_state() {
2155            let knowledge = KnowledgeBase::new();
2156            assert!(!validate(None, &knowledge, &terminal_state(), false).unwrap());
2157            let batch = received_batch(true);
2158            assert!(!validate(None, &knowledge, &parsed_state(&batch), true).unwrap());
2159            let (knowledge, receipt) = committed(terminal_batch()).await;
2160            assert!(validate(Some(receipt), &knowledge, &terminal_state(), false).unwrap());
2161            let (knowledge, receipt) = committed(received_batch(false)).await;
2162            assert!(validate(Some(receipt), &knowledge, &terminal_state(), true).unwrap());
2163            let state = parsed_state(&batch);
2164            let (knowledge, receipt) = committed(batch).await;
2165            assert!(validate(Some(receipt), &knowledge, &state, true).unwrap());
2166        }
2167
2168        #[tokio::test]
2169        async fn openapi_privacy_commit_rejects_lost_knowledge_duplicate_ids_and_write_mismatch() {
2170            let batch = received_batch(true);
2171            let state = parsed_state(&batch);
2172            let (knowledge, receipt) = committed(batch.clone()).await;
2173            assert!(validate(Some(receipt.clone()), &KnowledgeBase::new(), &state, true).is_err());
2174            let mut duplicate = batch;
2175            duplicate.push(duplicate[0].clone());
2176            let duplicate_receipt = receipt.with_test_committed_batch(
2177                duplicate.clone(),
2178                vec![KnowledgeWrite::Unchanged; duplicate.len()],
2179                knowledge.snapshot_for_subject(&subject()),
2180            );
2181            assert!(validate(Some(duplicate_receipt), &knowledge, &state, true).is_err());
2182            let bad = receipt.with_test_committed_batch(
2183                receipt.evidence().to_vec(),
2184                Vec::new(),
2185                knowledge.snapshot_for_subject(&subject()),
2186            );
2187            assert!(validate(Some(bad), &knowledge, &state, true).is_err());
2188        }
2189
2190        #[tokio::test]
2191        async fn openapi_privacy_commit_rejects_wrong_route_executor_stage_and_subject() {
2192            for (action, executor, stage) in [
2193                (
2194                    "synthetic.other-action",
2195                    "http.openapi-review",
2196                    DecisionExecutionStage::Passive,
2197                ),
2198                (
2199                    "web.review.openapi.document-replay@1",
2200                    "synthetic.other-executor",
2201                    DecisionExecutionStage::Passive,
2202                ),
2203                (
2204                    "web.review.openapi.document-replay@1",
2205                    "http.openapi-review",
2206                    DecisionExecutionStage::Active,
2207                ),
2208            ] {
2209                let (knowledge, receipt) =
2210                    committed_route(terminal_batch(), action, executor, stage, false).await;
2211                assert!(validate(Some(receipt), &knowledge, &terminal_state(), false).is_err());
2212            }
2213            let (knowledge, receipt) = committed(terminal_batch()).await;
2214            let different = EntityId::new("endpoint:https://sibling.invalid/").unwrap();
2215            assert!(validate_value_free_openapi_commits(
2216                &report(Some(receipt), false),
2217                &knowledge,
2218                &different,
2219                &candidate(),
2220                &terminal_state(),
2221            )
2222            .is_err());
2223        }
2224
2225        #[tokio::test]
2226        async fn openapi_privacy_commit_rejects_additional_public_identity_facts() {
2227            let batch = received_batch(true);
2228            let state = parsed_state(&batch);
2229            let (_, template) = committed(batch.clone()).await;
2230            for extra in [
2231                fact(
2232                    EvidenceKind::Content,
2233                    HttpEvidencePredicate::RESPONSE_BODY_SHA256.into_knowledge(),
2234                    EvidenceValue::Text("a".repeat(64)),
2235                    "response-body-sha256",
2236                ),
2237                custom(
2238                    "semantic_digest",
2239                    EvidenceValue::Text("unkeyed-body-derived-identity".to_owned()),
2240                    "semantic-digest",
2241                ),
2242                fact(
2243                    EvidenceKind::Custom("passive-secret-exposure".to_owned()),
2244                    KnowledgePredicate::new("web.secret-exposure", "response_body_lineage")
2245                        .unwrap(),
2246                    EvidenceValue::Boolean(true),
2247                    "synthetic-extra-lineage",
2248                ),
2249            ] {
2250                let mut changed = batch.clone();
2251                changed.push(extra);
2252                let (knowledge, receipt) = mutation(&template, changed);
2253                assert!(validate(Some(receipt), &knowledge, &state, true).is_err());
2254            }
2255        }
2256
2257        #[tokio::test]
2258        async fn openapi_privacy_commit_rejects_classification_lineage_and_terminal_mutations() {
2259            let batch = received_batch(true);
2260            let state = parsed_state(&batch);
2261            let (_, template) = committed(batch.clone()).await;
2262            let document_index = batch
2263                .iter()
2264                .position(|item| item.source().method() == "document")
2265                .unwrap();
2266            let lineage_index = batch
2267                .iter()
2268                .position(|item| item.predicate().name() == "active_review_body_lineage")
2269                .unwrap();
2270            for index in [document_index, lineage_index] {
2271                let mut changed = batch.clone();
2272                let original = &changed[index];
2273                let parents = original.origin().derivation().unwrap().parents()[1..].to_vec();
2274                changed[index] = replace(
2275                    original,
2276                    original.kind().clone(),
2277                    original.value().clone(),
2278                    original.source().clone(),
2279                    Some(parents),
2280                );
2281                let (knowledge, receipt) = mutation(&template, changed);
2282                assert!(validate(Some(receipt), &knowledge, &state, true).is_err());
2283            }
2284            let mut changed = batch.clone();
2285            changed[document_index] = replace(
2286                &batch[document_index],
2287                batch[document_index].kind().clone(),
2288                EvidenceValue::TextList(vec![
2289                    "version=3.1".to_owned(),
2290                    "paths=2".to_owned(),
2291                    "operations=1".to_owned(),
2292                ]),
2293                batch[document_index].source().clone(),
2294                None,
2295            );
2296            let (knowledge, receipt) = mutation(&template, changed);
2297            assert!(validate(Some(receipt), &knowledge, &state, true).is_err());
2298
2299            let terminal = terminal_batch();
2300            let (_, template) = committed(terminal.clone()).await;
2301            for (index, value, kind, method) in [
2302                (
2303                    0,
2304                    EvidenceValue::Unsigned(1),
2305                    EvidenceKind::Custom("openapi-review".to_owned()),
2306                    "response-body-size",
2307                ),
2308                (
2309                    1,
2310                    EvidenceValue::Boolean(false),
2311                    EvidenceKind::Custom("openapi-review".to_owned()),
2312                    "phase-terminal",
2313                ),
2314                (
2315                    0,
2316                    EvidenceValue::Unsigned(0),
2317                    EvidenceKind::Content,
2318                    "response-body-size",
2319                ),
2320                (
2321                    1,
2322                    EvidenceValue::Boolean(true),
2323                    EvidenceKind::Custom("openapi-review".to_owned()),
2324                    "wrong-method",
2325                ),
2326            ] {
2327                let mut changed = terminal.clone();
2328                let source = EvidenceSource::new("http.openapi-review", method)
2329                    .unwrap()
2330                    .with_correlation_id(CASE)
2331                    .unwrap();
2332                changed[index] = replace(&terminal[index], kind, value, source, None);
2333                let (knowledge, receipt) = mutation(&template, changed);
2334                assert!(validate(Some(receipt), &knowledge, &terminal_state(), false).is_err());
2335            }
2336        }
2337    }
2338    use super::*;
2339    use termivar_core::{ApiKnowledgePredicate, ApiResponseFormat};
2340
2341    #[test]
2342    fn selector_prefers_discovered_json_and_never_fans_out() {
2343        let root = Url::parse("https://example.test/base").unwrap();
2344        let selected = select_openapi_candidate(
2345            &root,
2346            [
2347                Url::parse("https://example.test/swagger.json").unwrap(),
2348                Url::parse("https://example.test/openapi.json").unwrap(),
2349                Url::parse("https://evil.invalid/openapi.json").unwrap(),
2350            ],
2351        )
2352        .unwrap();
2353        assert_eq!(
2354            selected.source,
2355            OpenApiCandidateSource::DiscoveredOpenApiJson
2356        );
2357        assert_eq!(selected.url.path(), "/openapi.json");
2358    }
2359    #[test]
2360    fn selector_uses_one_fixed_fallback() {
2361        let root = Url::parse("https://example.test/nested").unwrap();
2362        let selected = select_openapi_candidate(&root, []).unwrap();
2363        assert_eq!(selected.url.as_str(), "https://example.test/openapi.json");
2364        assert_eq!(
2365            selected.source,
2366            OpenApiCandidateSource::ConventionalOpenApiJson
2367        );
2368    }
2369
2370    #[test]
2371    fn selector_rejects_candidates_outside_the_path_and_url_bounds() {
2372        let root = Url::parse("https://example.test/").unwrap();
2373        let overlong_path = format!(
2374            "https://example.test/{}/openapi.json",
2375            "a".repeat(MAX_OPENAPI_CANDIDATE_PATH_BYTES)
2376        );
2377        let selected =
2378            select_openapi_candidate(&root, [Url::parse(&overlong_path).unwrap()]).unwrap();
2379        assert_eq!(
2380            selected.source,
2381            OpenApiCandidateSource::ConventionalOpenApiJson
2382        );
2383        assert_eq!(selected.url.path(), "/openapi.json");
2384        assert!(selected.url.as_str().len() <= MAX_OPENAPI_CANDIDATE_URL_BYTES);
2385        assert!(selected.url.path().len() <= MAX_OPENAPI_CANDIDATE_PATH_BYTES);
2386    }
2387
2388    #[test]
2389    fn selector_rejects_cross_origin_credentials_queries_and_fragments() {
2390        let root = Url::parse("https://example.test/root").unwrap();
2391        for hint in [
2392            "https://elsewhere.invalid/openapi.json",
2393            "https://user:secret@example.test/openapi.json",
2394            "https://example.test/openapi.json?variant=1",
2395            "https://example.test/openapi.json#fragment",
2396            "ftp://example.test/openapi.json",
2397        ] {
2398            let selected = select_openapi_candidate(&root, [Url::parse(hint).unwrap()]).unwrap();
2399            assert_eq!(
2400                selected.source,
2401                OpenApiCandidateSource::ConventionalOpenApiJson
2402            );
2403            assert_eq!(selected.url.as_str(), "https://example.test/openapi.json");
2404        }
2405    }
2406
2407    #[test]
2408    fn committed_json_evidence_feeds_the_existing_transport_neutral_reasoner() {
2409        for (json_compatible, expected) in [(true, true), (false, false)] {
2410            let knowledge = KnowledgeBase::new();
2411            let subject = EntityId::new(format!(
2412                "openapi-reasoning-fixture:{}",
2413                u8::from(json_compatible)
2414            ))
2415            .unwrap();
2416            let source = EvidenceSource::new(OPENAPI_EXECUTOR_ID, "response-json")
2417                .unwrap()
2418                .with_correlation_id("case:openapi-reasoning-fixture")
2419                .unwrap();
2420            knowledge
2421                .insert_evidence(Evidence::new(
2422                    subject.clone(),
2423                    EvidenceKind::Http,
2424                    HttpEvidencePredicate::RESPONSE_MEDIA_TYPE_JSON_COMPATIBLE.into_knowledge(),
2425                    EvidenceValue::Boolean(json_compatible),
2426                    source,
2427                    ConfidenceScore::MAX,
2428                ))
2429                .unwrap();
2430
2431            commit_api_reasoning_inputs(&knowledge, &subject).unwrap();
2432
2433            let snapshot = knowledge.snapshot_for_subject(&subject);
2434            let predicate = ApiKnowledgePredicate::RESPONSE_FORMAT.into_knowledge();
2435            let json = EvidenceValue::from(ApiResponseFormat::Json);
2436            assert_eq!(
2437                snapshot.hypotheses().iter().any(|hypothesis| {
2438                    hypothesis.predicate() == &predicate && hypothesis.value() == &json
2439                }),
2440                expected
2441            );
2442        }
2443    }
2444}