Skip to main content

termivar_scanner/web_runtime/api_visibility/
differential.rs

1//! Broker-backed, host-authorized JSON API visibility comparison.
2//!
3//! This is deliberately a runtime-owned side path rather than a
4//! `DecisionActionExecutor`: canonical comparison evidence belongs to an
5//! isolated `api-comparison:*` subject, not the endpoint action subject.
6
7use std::{collections::BTreeSet, fmt};
8
9use serde::Serialize;
10use sha2::{Digest, Sha256};
11use termivar_core::{
12    ApiVisibilityDimension, ApiVocabularyError, ComparisonId, EntityId, OpaqueContextId,
13    ResourceScopeId,
14};
15use thiserror::Error;
16
17use crate::web_runtime::authority::authenticated_transport_is_allowed;
18use crate::{
19    ApiComparisonProfile, ApiObservationCommitReceipt, ApiObservationError, ApiObservationReceipt,
20    ApiVisibilityReview, CanonicalizationVersion, ComparisonAlgorithmVersion, HttpEvidenceError,
21    HttpProbe, HttpProbeMethod, ProfiledApiVisibilityComparison, ProfiledApiVisibilityError,
22    ProjectionPolicyId, RuntimeLimitExceeded, RuntimeUsage, TransportDispatchAudit,
23    CURRENT_API_VISIBILITY_CANONICALIZATION_VERSION,
24};
25
26const TEMPLATE_DIGEST_DOMAIN: &[u8] = b"venom.api-visibility.request-template.v1\0";
27const OPERATION_DIGEST_DOMAIN: &[u8] = b"venom.api-visibility.runtime-operation.v1\0";
28const MAX_CONTEXT_HEADER_NAMES: usize = 32;
29const MAX_DIFFERENTIAL_HEADER_COUNT: usize = 64;
30const MAX_DIFFERENTIAL_HEADER_VALUE_BYTES: usize = 16 * 1024;
31const MAX_DIFFERENTIAL_TOTAL_HEADER_BYTES: usize = 64 * 1024;
32const MAX_DIFFERENTIAL_URL_BYTES: usize = 8 * 1024;
33const PRIMARY_AUTH_CONTEXT_HEADERS: &[&str] = &[
34    "authorization",
35    "cookie",
36    "x-access-token",
37    "x-api-key",
38    "x-auth-token",
39    "x-session-token",
40];
41const SUPPORTING_AUTH_CONTEXT_HEADERS: &[&str] = &["x-csrf-token", "x-xsrf-token"];
42
43mod execution;
44
45/// One host-declared authorization context and its bodyless HTTP probe.
46///
47/// The context identifier must be opaque and non-secret. Header values may
48/// contain credentials, so this type intentionally implements neither
49/// `Clone` nor `Serialize`, and its `Debug` output is fully redacted.
50pub struct ApiVisibilityContextProbe {
51    context_id: OpaqueContextId,
52    probe: HttpProbe,
53}
54
55impl ApiVisibilityContextProbe {
56    /// Creates one context-bound probe without performing I/O.
57    pub fn new(
58        context_id: impl Into<String>,
59        probe: HttpProbe,
60    ) -> Result<Self, ApiVisibilityDifferentialRequestError> {
61        Ok(Self {
62            context_id: OpaqueContextId::new(context_id)?,
63            probe,
64        })
65    }
66
67    /// Returns the caller-owned opaque context handle.
68    pub fn context_id(&self) -> &str {
69        self.context_id.as_str()
70    }
71
72    /// Returns the validated probe. Its own `Debug` output redacts values.
73    pub fn probe(&self) -> &HttpProbe {
74        &self.probe
75    }
76}
77
78impl fmt::Debug for ApiVisibilityContextProbe {
79    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
80        formatter
81            .debug_struct("ApiVisibilityContextProbe")
82            .field("context_id", &"<redacted>")
83            .field("probe", &"<redacted>")
84            .finish()
85    }
86}
87
88/// A fully validated, explicit JSON authorization-context pair.
89///
90/// The first native slice intentionally accepts only two `GET` requests for
91/// the exact same URL. Header differences are permitted only for the explicit
92/// context-owned header-name set. This prevents an unrelated method, path,
93/// query, or representation change from being misclassified as authorization
94/// visibility.
95pub struct ApiVisibilityDifferentialRequest {
96    comparison_id: ComparisonId,
97    resource_scope: EntityId,
98    resource_scope_id: ResourceScopeId,
99    control: ApiVisibilityContextProbe,
100    candidate: ApiVisibilityContextProbe,
101    context_header_names: BTreeSet<String>,
102    profile: ApiComparisonProfile,
103    dimension: ApiVisibilityDimension,
104    observed_at_ms: u64,
105    request_template_sha256: String,
106    operation_sha256: String,
107}
108
109impl ApiVisibilityDifferentialRequest {
110    /// Validates one explicit authorization-context comparison before I/O.
111    #[allow(clippy::too_many_arguments)]
112    pub fn new<I, S>(
113        comparison_id: impl Into<String>,
114        resource_scope: EntityId,
115        control: ApiVisibilityContextProbe,
116        candidate: ApiVisibilityContextProbe,
117        context_header_names: I,
118        profile: ApiComparisonProfile,
119        dimension: ApiVisibilityDimension,
120        observed_at_ms: u64,
121    ) -> Result<Self, ApiVisibilityDifferentialRequestError>
122    where
123        I: IntoIterator<Item = S>,
124        S: Into<String>,
125    {
126        let comparison_id = ComparisonId::new(comparison_id)?;
127        let resource_scope_id = ResourceScopeId::new(resource_scope.as_str())?;
128        if control.context_id == candidate.context_id {
129            return Err(ApiVisibilityDifferentialRequestError::IdenticalContextHandles);
130        }
131        for probe in [&control.probe, &candidate.probe] {
132            if probe.method() != HttpProbeMethod::Get {
133                return Err(ApiVisibilityDifferentialRequestError::UnsupportedMethod);
134            }
135            if probe.url().fragment().is_some() {
136                return Err(ApiVisibilityDifferentialRequestError::UrlFragment);
137            }
138        }
139        if control.probe.url() != candidate.probe.url() {
140            return Err(ApiVisibilityDifferentialRequestError::RequestTargetMismatch);
141        }
142        if control.probe.url().as_str().len() > MAX_DIFFERENTIAL_URL_BYTES {
143            return Err(ApiVisibilityDifferentialRequestError::RequestTargetTooLong);
144        }
145        if !authenticated_transport_is_allowed(control.probe.url()) {
146            return Err(ApiVisibilityDifferentialRequestError::InsecureAuthenticatedTransport);
147        }
148        if !matches!(
149            dimension,
150            ApiVisibilityDimension::Fields
151                | ApiVisibilityDimension::Resources
152                | ApiVisibilityDimension::Status
153        ) {
154            return Err(ApiVisibilityDifferentialRequestError::UnsupportedDimension);
155        }
156
157        let context_header_names = context_header_names
158            .into_iter()
159            .map(Into::into)
160            .map(|name: String| name.trim().to_ascii_lowercase())
161            .collect::<BTreeSet<_>>();
162        if context_header_names.is_empty() {
163            return Err(ApiVisibilityDifferentialRequestError::EmptyContextHeaders);
164        }
165        if context_header_names.len() > MAX_CONTEXT_HEADER_NAMES
166            || context_header_names.iter().any(String::is_empty)
167        {
168            return Err(ApiVisibilityDifferentialRequestError::InvalidContextHeaders);
169        }
170        if context_header_names
171            .iter()
172            .any(|name| !is_auth_context_header(name))
173            || !context_header_names
174                .iter()
175                .any(|name| PRIMARY_AUTH_CONTEXT_HEADERS.contains(&name.as_str()))
176        {
177            return Err(ApiVisibilityDifferentialRequestError::UnsupportedContextHeader);
178        }
179
180        let all_header_names = control
181            .probe
182            .headers()
183            .keys()
184            .chain(candidate.probe.headers().keys())
185            .collect::<BTreeSet<_>>();
186        if all_header_names.len() > MAX_DIFFERENTIAL_HEADER_COUNT {
187            return Err(ApiVisibilityDifferentialRequestError::TooManyHeaders);
188        }
189        for probe in [&control.probe, &candidate.probe] {
190            let mut total_header_bytes = 0_usize;
191            for (name, value) in probe.headers() {
192                if value.len() > MAX_DIFFERENTIAL_HEADER_VALUE_BYTES {
193                    return Err(ApiVisibilityDifferentialRequestError::HeaderValueTooLong);
194                }
195                total_header_bytes = total_header_bytes
196                    .saturating_add(name.len())
197                    .saturating_add(value.len());
198            }
199            if total_header_bytes > MAX_DIFFERENTIAL_TOTAL_HEADER_BYTES {
200                return Err(ApiVisibilityDifferentialRequestError::TotalHeaderBytesTooLarge);
201            }
202        }
203        if context_header_names.iter().any(|name| {
204            !all_header_names
205                .iter()
206                .any(|present| present.as_str() == name)
207        }) {
208            return Err(ApiVisibilityDifferentialRequestError::UnusedContextHeader);
209        }
210        if all_header_names.iter().any(|name| {
211            !context_header_names.contains(name.as_str())
212                && control.probe.headers().get(name.as_str())
213                    != candidate.probe.headers().get(name.as_str())
214        }) {
215            return Err(ApiVisibilityDifferentialRequestError::RequestTemplateMismatch);
216        }
217
218        if !primary_context_headers_differ(&control.probe, &candidate.probe, &context_header_names)
219        {
220            return Err(ApiVisibilityDifferentialRequestError::IdenticalAuthorizationContext);
221        }
222
223        let request_template_sha256 =
224            request_template_digest(&control.probe, &context_header_names, &all_header_names);
225        let operation_sha256 = operation_digest(
226            &comparison_id,
227            &resource_scope,
228            &control.context_id,
229            &candidate.context_id,
230            dimension,
231            observed_at_ms,
232            profile.projection_policy_id().as_bytes(),
233            &request_template_sha256,
234        );
235        Ok(Self {
236            comparison_id,
237            resource_scope,
238            resource_scope_id,
239            control,
240            candidate,
241            context_header_names,
242            profile,
243            dimension,
244            observed_at_ms,
245            request_template_sha256,
246            operation_sha256,
247        })
248    }
249
250    /// Returns the logical resource asserted by the authorized host.
251    pub fn resource_scope(&self) -> &EntityId {
252        &self.resource_scope
253    }
254
255    /// Returns the selected comparison dimension.
256    pub const fn dimension(&self) -> ApiVisibilityDimension {
257        self.dimension
258    }
259
260    /// Returns the raw-value-free projection policy.
261    pub const fn profile(&self) -> &ApiComparisonProfile {
262        &self.profile
263    }
264
265    /// Returns the explicit deterministic observation time.
266    pub const fn observed_at_ms(&self) -> u64 {
267        self.observed_at_ms
268    }
269}
270
271impl fmt::Debug for ApiVisibilityDifferentialRequest {
272    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
273        formatter
274            .debug_struct("ApiVisibilityDifferentialRequest")
275            .field("comparison_id", &"<redacted>")
276            .field("resource_scope", &"<redacted>")
277            .field("control", &"<redacted>")
278            .field("candidate", &"<redacted>")
279            .field("context_header_names", &self.context_header_names)
280            .field("profile", &self.profile)
281            .field("dimension", &self.dimension)
282            .field("observed_at_ms", &self.observed_at_ms)
283            .field("request_template_sha256", &"<redacted>")
284            .field("operation_sha256", &"<redacted>")
285            .finish()
286    }
287}
288
289/// Validation failure for an authorization-context pair.
290#[derive(Debug, Error)]
291#[non_exhaustive]
292pub enum ApiVisibilityDifferentialRequestError {
293    /// A typed comparison, context, or scope identifier was invalid.
294    #[error(transparent)]
295    Vocabulary(#[from] ApiVocabularyError),
296    /// Control and candidate handles must describe distinct contexts.
297    #[error("API visibility contexts must use distinct opaque handles")]
298    IdenticalContextHandles,
299    /// The first native capability accepts discovery-only GET requests.
300    #[error("API visibility differential requests must use GET")]
301    UnsupportedMethod,
302    /// URL fragments are not transmitted and cannot participate in identity.
303    #[error("API visibility differential requests must not contain URL fragments")]
304    UrlFragment,
305    /// Authorization contexts must request the exact same URL.
306    #[error("API visibility authorization contexts must use the same exact request target")]
307    RequestTargetMismatch,
308    /// The bounded native slice rejects excessively large target identities.
309    #[error("API visibility request target exceeds the compiled length limit")]
310    RequestTargetTooLong,
311    /// Credentials require TLS except on an exact loopback fixture target.
312    #[error("API visibility authorization contexts require HTTPS outside loopback fixtures")]
313    InsecureAuthenticatedTransport,
314    /// The comparator does not implement the requested dimension.
315    #[error("API visibility differential request uses an unsupported dimension")]
316    UnsupportedDimension,
317    /// At least one header must be declared context-owned.
318    #[error("API visibility differential request requires context-owned header names")]
319    EmptyContextHeaders,
320    /// The context-header set was malformed or exceeded its compiled bound.
321    #[error("API visibility context-header set is invalid")]
322    InvalidContextHeaders,
323    /// Only explicit credential and supporting anti-CSRF headers are accepted.
324    #[error("API visibility context-header set contains a non-credential header")]
325    UnsupportedContextHeader,
326    /// The complete request header set exceeded its compiled count ceiling.
327    #[error("API visibility request contains too many headers")]
328    TooManyHeaders,
329    /// One request header value exceeded its compiled byte ceiling.
330    #[error("API visibility request header value exceeds the compiled byte limit")]
331    HeaderValueTooLong,
332    /// One request's complete header material exceeded its compiled byte ceiling.
333    #[error("API visibility request headers exceed the compiled total byte limit")]
334    TotalHeaderBytesTooLarge,
335    /// A declared context header did not occur in either request.
336    #[error("API visibility context-header set contains an unused name")]
337    UnusedContextHeader,
338    /// A non-context request header differed across the pair.
339    #[error("API visibility request templates differ outside authorization context headers")]
340    RequestTemplateMismatch,
341    /// Distinct labels cannot disguise identical authorization material.
342    #[error("API visibility probes contain identical authorization context headers")]
343    IdenticalAuthorizationContext,
344}
345
346/// Which half of a differential pair produced a receipt or stop.
347#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
348#[serde(rename_all = "snake_case")]
349#[non_exhaustive]
350pub enum ApiVisibilityLeg {
351    /// The baseline/control authorization context.
352    Control,
353    /// The candidate authorization context.
354    Candidate,
355}
356
357/// Raw-value-free transport receipt for one completed leg.
358///
359/// Digests are pseudonymous and may be dictionary-testable. They are intended
360/// for authorized audit/replay correlation, not as secret-protection tokens.
361#[derive(Clone, PartialEq, Eq, Serialize)]
362pub struct ApiVisibilityLegReceipt {
363    leg: ApiVisibilityLeg,
364    status: u16,
365    retained_body_bytes: u64,
366    body_truncated: bool,
367    json_compatible_media_type: bool,
368    request_template_sha256: String,
369    response_body_sha256: String,
370}
371
372impl ApiVisibilityLegReceipt {
373    /// Returns whether this receipt describes control or candidate.
374    pub const fn leg(&self) -> ApiVisibilityLeg {
375        self.leg
376    }
377
378    /// Returns the exact response status.
379    pub const fn status(&self) -> u16 {
380        self.status
381    }
382
383    /// Returns bytes retained under broker and per-request limits.
384    pub const fn retained_body_bytes(&self) -> u64 {
385        self.retained_body_bytes
386    }
387
388    /// Returns whether the response body was incomplete.
389    pub const fn body_truncated(&self) -> bool {
390        self.body_truncated
391    }
392
393    /// Returns whether the single normalized media type was JSON-compatible.
394    pub const fn json_compatible_media_type(&self) -> bool {
395        self.json_compatible_media_type
396    }
397
398    /// Returns the context-value-free request-template digest.
399    pub fn request_template_sha256(&self) -> &str {
400        &self.request_template_sha256
401    }
402
403    /// Returns the domain-separated retained-body digest.
404    pub fn response_body_sha256(&self) -> &str {
405        &self.response_body_sha256
406    }
407}
408
409impl fmt::Debug for ApiVisibilityLegReceipt {
410    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
411        formatter
412            .debug_struct("ApiVisibilityLegReceipt")
413            .field("leg", &self.leg)
414            .field("status", &self.status)
415            .field("retained_body_bytes", &self.retained_body_bytes)
416            .field("body_truncated", &self.body_truncated)
417            .field(
418                "json_compatible_media_type",
419                &self.json_compatible_media_type,
420            )
421            .field("request_template_sha256", &"<redacted>")
422            .field("response_body_sha256", &"<redacted>")
423            .finish()
424    }
425}
426
427/// Monotonic transport audit retained on every post-start exit.
428#[derive(Clone, PartialEq, Eq, Serialize)]
429pub struct ApiVisibilityDifferentialAudit {
430    comparison_id: ComparisonId,
431    resource_scope_id: ResourceScopeId,
432    control_context_id: OpaqueContextId,
433    candidate_context_id: OpaqueContextId,
434    dimension: ApiVisibilityDimension,
435    observed_at_ms: u64,
436    comparator_version: ComparisonAlgorithmVersion,
437    canonicalization_version: CanonicalizationVersion,
438    projection_policy_id: ProjectionPolicyId,
439    request_template_sha256: String,
440    operation_sha256: String,
441    control: Option<ApiVisibilityLegReceipt>,
442    candidate: Option<ApiVisibilityLegReceipt>,
443    usage: RuntimeUsage,
444    transport: TransportDispatchAudit,
445}
446
447impl ApiVisibilityDifferentialAudit {
448    fn for_request(request: &ApiVisibilityDifferentialRequest) -> Self {
449        Self {
450            comparison_id: request.comparison_id.clone(),
451            resource_scope_id: request.resource_scope_id.clone(),
452            control_context_id: request.control.context_id.clone(),
453            candidate_context_id: request.candidate.context_id.clone(),
454            dimension: request.dimension,
455            observed_at_ms: request.observed_at_ms,
456            comparator_version: request.profile.algorithm_version(),
457            canonicalization_version: CURRENT_API_VISIBILITY_CANONICALIZATION_VERSION,
458            projection_policy_id: request.profile.projection_policy_id(),
459            request_template_sha256: request.request_template_sha256.clone(),
460            operation_sha256: request.operation_sha256.clone(),
461            control: None,
462            candidate: None,
463            usage: RuntimeUsage::default(),
464            transport: TransportDispatchAudit::default(),
465        }
466    }
467
468    /// Returns the opaque, non-secret comparison handle.
469    pub const fn comparison_id(&self) -> &ComparisonId {
470        &self.comparison_id
471    }
472
473    /// Returns the opaque, non-secret logical-resource handle.
474    pub const fn resource_scope_id(&self) -> &ResourceScopeId {
475        &self.resource_scope_id
476    }
477
478    /// Returns the opaque, non-secret control-context handle.
479    pub const fn control_context_id(&self) -> &OpaqueContextId {
480        &self.control_context_id
481    }
482
483    /// Returns the opaque, non-secret candidate-context handle.
484    pub const fn candidate_context_id(&self) -> &OpaqueContextId {
485        &self.candidate_context_id
486    }
487
488    /// Returns the selected comparison dimension.
489    pub const fn dimension(&self) -> ApiVisibilityDimension {
490        self.dimension
491    }
492
493    /// Returns the caller-supplied deterministic observation time.
494    pub const fn observed_at_ms(&self) -> u64 {
495        self.observed_at_ms
496    }
497
498    /// Returns the comparator version captured before I/O.
499    pub const fn comparator_version(&self) -> ComparisonAlgorithmVersion {
500        self.comparator_version
501    }
502
503    /// Returns the canonical tree-hash version captured before I/O.
504    pub const fn canonicalization_version(&self) -> CanonicalizationVersion {
505        self.canonicalization_version
506    }
507
508    /// Returns the projection-policy identity captured before I/O.
509    pub const fn projection_policy_id(&self) -> ProjectionPolicyId {
510        self.projection_policy_id
511    }
512
513    /// Returns the authorization-value-free request-template digest.
514    pub fn request_template_sha256(&self) -> &str {
515        &self.request_template_sha256
516    }
517
518    /// Returns the domain-separated identity of the complete comparison intent.
519    pub fn operation_sha256(&self) -> &str {
520        &self.operation_sha256
521    }
522
523    /// Returns a completed control receipt, when one exists.
524    pub const fn control(&self) -> Option<&ApiVisibilityLegReceipt> {
525        self.control.as_ref()
526    }
527
528    /// Returns a completed candidate receipt, when one exists.
529    pub const fn candidate(&self) -> Option<&ApiVisibilityLegReceipt> {
530        self.candidate.as_ref()
531    }
532
533    /// Returns broker-owned resource accounting at the exit boundary.
534    pub const fn usage(&self) -> &RuntimeUsage {
535        &self.usage
536    }
537
538    /// Returns bounded, dispatch-ordered transport receipts for both legs.
539    pub const fn transport(&self) -> &TransportDispatchAudit {
540        &self.transport
541    }
542}
543
544impl fmt::Debug for ApiVisibilityDifferentialAudit {
545    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
546        formatter
547            .debug_struct("ApiVisibilityDifferentialAudit")
548            .field("comparison_id", &self.comparison_id)
549            .field("resource_scope_id", &self.resource_scope_id)
550            .field("control_context_id", &self.control_context_id)
551            .field("candidate_context_id", &self.candidate_context_id)
552            .field("dimension", &self.dimension)
553            .field("observed_at_ms", &self.observed_at_ms)
554            .field("comparator_version", &self.comparator_version)
555            .field("canonicalization_version", &self.canonicalization_version)
556            .field("projection_policy_id", &self.projection_policy_id)
557            .field("request_template_sha256", &"<redacted>")
558            .field("operation_sha256", &"<redacted>")
559            .field("control", &self.control)
560            .field("candidate", &self.candidate)
561            .field("usage", &self.usage)
562            .field("transport", &self.transport)
563            .finish()
564    }
565}
566
567/// Why a complete comparison could not be produced.
568#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
569#[serde(rename_all = "snake_case")]
570#[non_exhaustive]
571pub enum ApiVisibilityInconclusiveReason {
572    /// The request was outside an applicable executor contract.
573    NotApplicable,
574    /// Host or transport policy denied the request.
575    BlockedByPolicy,
576    /// The network attempt failed after accounting.
577    TransportFailure,
578    /// The per-request timeout elapsed.
579    RequestTimeout,
580    /// The transport/executor failed without a narrower category.
581    ExecutorFailure,
582    /// A rate-limited response cannot establish visibility.
583    RateLimited,
584    /// A server-side error cannot establish visibility.
585    ServerError,
586    /// The complete response exceeded a retention boundary.
587    TruncatedResponse,
588    /// The response did not declare one JSON-compatible media type.
589    NonJsonResponse,
590    /// The bounded body was not valid JSON.
591    MalformedJson,
592    /// JSON validation or canonicalization rejected the bounded document.
593    CanonicalizationRejected,
594}
595
596/// Terminal handling state for one single-use differential runtime.
597#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
598#[serde(rename_all = "snake_case")]
599#[non_exhaustive]
600pub enum ApiVisibilityDifferentialDisposition {
601    /// The selected comparison dimension was equivalent.
602    NoDifferenceObserved,
603    /// A difference committed but did not materialize a canonical hypothesis.
604    UnresolvedDifference,
605    /// A weak, supported boundary hypothesis requires authorized human review.
606    AwaitHumanReview,
607    /// A leg was incomplete or semantically unsuitable for comparison.
608    Inconclusive,
609    /// The host cancelled this single-use runtime.
610    CancelledByHost,
611    /// The host-owned runtime envelope stopped execution.
612    RuntimeBudgetLimit,
613}
614
615/// Complete audit report for a successful or safely stopped pair.
616#[derive(Clone, Serialize)]
617pub struct RuntimeApiVisibilityRunReport {
618    disposition: ApiVisibilityDifferentialDisposition,
619    stopped_leg: Option<ApiVisibilityLeg>,
620    inconclusive_reason: Option<ApiVisibilityInconclusiveReason>,
621    audit: ApiVisibilityDifferentialAudit,
622    comparison: Option<ProfiledApiVisibilityComparison>,
623    observation: Option<ApiObservationReceipt>,
624    review: Option<ApiVisibilityReview>,
625    limit_exceeded: Option<RuntimeLimitExceeded>,
626}
627
628impl RuntimeApiVisibilityRunReport {
629    /// Returns the non-vulnerability terminal disposition.
630    pub const fn disposition(&self) -> ApiVisibilityDifferentialDisposition {
631        self.disposition
632    }
633
634    /// Returns the leg at which an incomplete run stopped.
635    pub const fn stopped_leg(&self) -> Option<ApiVisibilityLeg> {
636        self.stopped_leg
637    }
638
639    /// Returns the structured reason for an inconclusive result.
640    pub const fn inconclusive_reason(&self) -> Option<ApiVisibilityInconclusiveReason> {
641        self.inconclusive_reason
642    }
643
644    /// Returns raw-value-free transport receipts and final usage.
645    pub const fn audit(&self) -> &ApiVisibilityDifferentialAudit {
646        &self.audit
647    }
648
649    /// Returns the complete V3 replay envelope only for a complete pair.
650    pub const fn comparison(&self) -> Option<&ProfiledApiVisibilityComparison> {
651        self.comparison.as_ref()
652    }
653
654    /// Returns the atomic observation/reasoning receipt only after ingestion.
655    pub const fn observation(&self) -> Option<&ApiObservationReceipt> {
656        self.observation.as_ref()
657    }
658
659    /// Returns the exact committed review projection only after ingestion.
660    pub const fn review(&self) -> Option<&ApiVisibilityReview> {
661        self.review.as_ref()
662    }
663
664    /// Returns the structured budget limit for a resource stop.
665    pub const fn limit_exceeded(&self) -> Option<&RuntimeLimitExceeded> {
666        self.limit_exceeded.as_ref()
667    }
668}
669
670impl fmt::Debug for RuntimeApiVisibilityRunReport {
671    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
672        formatter
673            .debug_struct("RuntimeApiVisibilityRunReport")
674            .field("disposition", &self.disposition)
675            .field("stopped_leg", &self.stopped_leg)
676            .field("inconclusive_reason", &self.inconclusive_reason)
677            .field("audit", &self.audit)
678            .field("comparison", &self.comparison)
679            .field("observation", &self.observation)
680            .field("review", &self.review)
681            .field("limit_exceeded", &self.limit_exceeded)
682            .finish()
683    }
684}
685
686/// Failure after configuration validation that cannot be represented as a
687/// safe target outcome. Each post-start variant preserves transport audit.
688#[derive(Debug, Error)]
689#[non_exhaustive]
690pub enum RuntimeApiVisibilityExecutionError {
691    /// API reasoning must be installed before native comparison.
692    #[error("API visibility reasoning is disabled for this runtime")]
693    ApiReasoningDisabled,
694    /// A single-use runtime cannot execute another workflow.
695    #[error("standard web decision runtime has already started")]
696    AlreadyStarted,
697    /// Both probes must match the runtime's exact authorized target.
698    #[error("API visibility pair target does not match the runtime target")]
699    RuntimeTargetMismatch,
700    /// A context-isolated transport pool could not be constructed pre-I/O.
701    #[error("failed to construct isolated API visibility transport: {source}")]
702    TransportSetup {
703        /// Redirect-disabled HTTP client construction failure.
704        #[source]
705        source: HttpEvidenceError,
706    },
707    /// A supposedly compatible profiled comparison failed closed.
708    #[error("API visibility comparison failed after transport completed: {source}")]
709    Comparison {
710        /// Raw-value-free transport audit.
711        audit: Box<ApiVisibilityDifferentialAudit>,
712        /// Comparator failure without response content.
713        #[source]
714        source: ProfiledApiVisibilityError,
715    },
716    /// A complete V3 comparison could not be converted into an observation.
717    #[error("API visibility observation construction failed after comparison: {source}")]
718    ObservationBuild {
719        /// Raw-value-free transport audit.
720        audit: Box<ApiVisibilityDifferentialAudit>,
721        /// Replayable V3 comparison envelope produced before the failure.
722        comparison: Box<ProfiledApiVisibilityComparison>,
723        /// Observation construction failure without response content.
724        #[source]
725        source: ProfiledApiVisibilityError,
726    },
727    /// Observation storage or reasoning failed after a complete comparison.
728    #[error("API visibility observation failed after transport completed: {source}")]
729    Observation {
730        /// Raw-value-free transport audit.
731        audit: Box<ApiVisibilityDifferentialAudit>,
732        /// Replayable V3 comparison envelope.
733        comparison: Box<ProfiledApiVisibilityComparison>,
734        /// Atomic-ingestion or post-commit reasoning failure.
735        #[source]
736        source: ApiObservationError,
737    },
738    /// A committed canonical observation could not be projected exactly.
739    #[error("committed API visibility observation has no canonical review projection")]
740    ReviewProjection {
741        /// Raw-value-free transport audit.
742        audit: Box<ApiVisibilityDifferentialAudit>,
743        /// Replayable V3 comparison envelope.
744        comparison: Box<ProfiledApiVisibilityComparison>,
745        /// Observation and reasoning receipt already committed.
746        observation: Box<ApiObservationReceipt>,
747    },
748}
749
750impl RuntimeApiVisibilityExecutionError {
751    /// Returns transport audit captured after the runtime started.
752    pub fn audit(&self) -> Option<&ApiVisibilityDifferentialAudit> {
753        match self {
754            Self::Comparison { audit, .. }
755            | Self::ObservationBuild { audit, .. }
756            | Self::Observation { audit, .. }
757            | Self::ReviewProjection { audit, .. } => Some(audit),
758            Self::ApiReasoningDisabled
759            | Self::AlreadyStarted
760            | Self::RuntimeTargetMismatch
761            | Self::TransportSetup { .. } => None,
762        }
763    }
764
765    /// Returns a complete comparison retained across a later failure.
766    pub fn comparison(&self) -> Option<&ProfiledApiVisibilityComparison> {
767        match self {
768            Self::ObservationBuild { comparison, .. }
769            | Self::Observation { comparison, .. }
770            | Self::ReviewProjection { comparison, .. } => Some(comparison),
771            Self::ApiReasoningDisabled
772            | Self::AlreadyStarted
773            | Self::RuntimeTargetMismatch
774            | Self::TransportSetup { .. }
775            | Self::Comparison { .. } => None,
776        }
777    }
778
779    /// Returns an observation committed before a later reasoning failure.
780    pub fn committed_observation(&self) -> Option<&ApiObservationCommitReceipt> {
781        match self {
782            Self::Observation { source, .. } => source.committed_observation(),
783            Self::ReviewProjection { observation, .. } => Some(observation.commit()),
784            Self::ApiReasoningDisabled
785            | Self::AlreadyStarted
786            | Self::RuntimeTargetMismatch
787            | Self::TransportSetup { .. }
788            | Self::ObservationBuild { .. }
789            | Self::Comparison { .. } => None,
790        }
791    }
792}
793
794fn primary_context_headers_differ(
795    control: &HttpProbe,
796    candidate: &HttpProbe,
797    context_header_names: &BTreeSet<String>,
798) -> bool {
799    context_header_names
800        .iter()
801        .filter(|name| PRIMARY_AUTH_CONTEXT_HEADERS.contains(&name.as_str()))
802        .any(|name| control.headers().get(name) != candidate.headers().get(name))
803}
804
805fn is_auth_context_header(name: &str) -> bool {
806    PRIMARY_AUTH_CONTEXT_HEADERS.contains(&name) || SUPPORTING_AUTH_CONTEXT_HEADERS.contains(&name)
807}
808
809fn request_template_digest(
810    probe: &HttpProbe,
811    context_header_names: &BTreeSet<String>,
812    all_header_names: &BTreeSet<&String>,
813) -> String {
814    let mut hasher = Sha256::new();
815    hasher.update(TEMPLATE_DIGEST_DOMAIN);
816    update_framed(&mut hasher, probe.method().as_str().as_bytes());
817    update_framed(&mut hasher, probe.url().as_str().as_bytes());
818    for name in all_header_names {
819        update_framed(&mut hasher, name.as_bytes());
820        if context_header_names.contains(name.as_str()) {
821            update_framed(&mut hasher, b"<context-owned>");
822        } else if let Some(value) = probe.headers().get(name.as_str()) {
823            update_framed(&mut hasher, value.as_bytes());
824        }
825    }
826    encode_digest(hasher.finalize().as_slice())
827}
828
829#[allow(clippy::too_many_arguments)]
830fn operation_digest(
831    comparison_id: &ComparisonId,
832    resource_scope: &EntityId,
833    control_context_id: &OpaqueContextId,
834    candidate_context_id: &OpaqueContextId,
835    dimension: ApiVisibilityDimension,
836    observed_at_ms: u64,
837    projection_policy_id: &[u8; 32],
838    request_template_sha256: &str,
839) -> String {
840    let mut hasher = Sha256::new();
841    hasher.update(OPERATION_DIGEST_DOMAIN);
842    for bytes in [
843        comparison_id.as_str().as_bytes(),
844        resource_scope.as_str().as_bytes(),
845        control_context_id.as_str().as_bytes(),
846        candidate_context_id.as_str().as_bytes(),
847        dimension.as_str().as_bytes(),
848        request_template_sha256.as_bytes(),
849    ] {
850        update_framed(&mut hasher, bytes);
851    }
852    update_framed(&mut hasher, &observed_at_ms.to_be_bytes());
853    update_framed(&mut hasher, projection_policy_id);
854    encode_digest(hasher.finalize().as_slice())
855}
856
857fn update_framed(hasher: &mut Sha256, bytes: &[u8]) {
858    hasher.update(u64::try_from(bytes.len()).unwrap_or(u64::MAX).to_be_bytes());
859    hasher.update(bytes);
860}
861
862fn encode_digest(bytes: &[u8]) -> String {
863    const HEX: &[u8; 16] = b"0123456789abcdef";
864    let mut encoded = String::with_capacity(bytes.len().saturating_mul(2));
865    for byte in bytes {
866        encoded.push(char::from(HEX[usize::from(byte >> 4)]));
867        encoded.push(char::from(HEX[usize::from(byte & 0x0f)]));
868    }
869    encoded
870}
871
872#[cfg(test)]
873#[path = "differential_tests.rs"]
874mod tests;