Skip to main content

termivar_scanner/web_runtime/
web_assessment.rs

1//! Bounded exact-origin orchestration over the standard web decision runtime.
2//!
3//! Discovery is names-only knowledge. It neither classifies vulnerabilities
4//! nor authorizes another origin. Every subject shares one metered transport,
5//! resource budget, knowledge base, cancellation token, and absolute deadline.
6
7use std::{
8    collections::{BTreeMap, BTreeSet},
9    fmt,
10    sync::Arc,
11    time::Duration,
12};
13
14#[cfg(feature = "reporting")]
15use std::time::SystemTime;
16
17use termivar_core::predicates::WebDiscoveryEvidencePredicate;
18use termivar_core::{
19    DerivationAlgorithm, EntityId, Evidence, EvidenceDerivation, EvidenceId, EvidenceKind,
20    EvidenceOrigin, EvidenceSource, EvidenceValue, HttpEvidencePredicate, PredicateDescriptor,
21};
22use thiserror::Error;
23use tokio::sync::watch;
24use tokio_util::sync::CancellationToken;
25use url::Url;
26
27#[cfg(feature = "graphql-review")]
28use crate::graphql_review::MAX_GRAPHQL_ACTIVE_VERIFICATIONS;
29#[cfg(feature = "graphql-review")]
30use crate::DefenseInteractionClass;
31
32#[cfg(any(
33    feature = "authorization-review",
34    feature = "jwt-target-acceptance-review",
35    feature = "supplied-session-review"
36))]
37use super::authenticated_transport_is_allowed;
38#[cfg(feature = "authorization-review")]
39use crate::authorization_review::{
40    AuthorizationPrincipalPair, AuthorizationReviewOutcome, AuthorizationReviewPolicy,
41};
42#[cfg(feature = "jwt-target-acceptance-review")]
43use crate::jwt_policy_review::JwtTargetAcceptanceRuntimeInput;
44
45#[cfg(feature = "graphql-review")]
46use super::graphql_runtime::{
47    execute_graphql_review, graphql_endpoint_hints, CommittedGraphqlReview, GraphqlResponsePrivacy,
48    GraphqlRuntimeResult, GraphqlRuntimeStop,
49};
50#[cfg(feature = "jwt-target-acceptance-review")]
51use super::jwt_target_acceptance_runtime::JwtTargetAcceptanceRuntimeConfig;
52#[cfg(feature = "openapi-review")]
53use super::openapi_runtime::{
54    select_openapi_candidate, CommittedOpenApiReview, OpenApiReviewConfig, OpenApiRuntimeOutcome,
55    OpenApiRuntimeResult, WebAssessmentOpenApiAudit, MAX_OPENAPI_REVIEW_ACTIVE_VERIFICATIONS,
56};
57#[cfg(feature = "recon-ct-provider")]
58use super::recon_ct_runtime::{
59    execute_recon_ct_provider, recon_ct_provider_scope_is_valid, WebAssessmentReconCtProviderAudit,
60};
61#[cfg(feature = "authorization-review")]
62use super::resource_authorization_runtime::{
63    CommittedResourceAuthorizationReview, ResourceAuthorizationReviewConfig,
64    ResourceAuthorizationRuntimeResult, WebAssessmentAuthorizationAudit,
65    MAX_AUTHORIZATION_REVIEW_ACTIVE_VERIFICATIONS,
66};
67#[cfg(feature = "rest-review")]
68use super::rest_runtime::{
69    CommittedRestReview, RestRuntimeOutcome, RestRuntimeResult, WebAssessmentRestAudit,
70    MAX_REST_REVIEW_ACTIVE_VERIFICATIONS,
71};
72#[cfg(feature = "secret-exposure-review")]
73use super::secret_exposure::{
74    is_secret_exposure_response_lineage, secret_exposure_response_lineage_id,
75    secret_exposure_response_parent_ids, CommittedSecretExposureLedger, SecretExposureCollector,
76    WebAssessmentSecretExposureAudit, RESPONSE_BODY_LINEAGE, SECRET_EXPOSURE_NAMESPACE,
77};
78#[cfg(feature = "ssrf-oast-review")]
79use super::ssrf_oast_runtime::{
80    CommittedSsrfOastReview, SsrfOastReviewConfig, SsrfOastRuntimeResult,
81    WebAssessmentSsrfOastAudit, MAX_SSRF_OAST_REVIEW_ACTIVE_VERIFICATIONS,
82};
83#[cfg(feature = "supplied-session-review")]
84use super::supplied_session_runtime::{
85    SuppliedSessionCommittedResourceObserver, SuppliedSessionRuntimeConfig,
86    WebAssessmentSuppliedSessionAudit,
87};
88#[cfg(feature = "tls-negotiation-review")]
89use super::tls_negotiation_review::{
90    tls_negotiation_review_target_is_supported, WebAssessmentTlsNegotiationReviewAudit,
91    TLS_NEGOTIATION_ACTIVE_VERIFICATION_ALLOWANCE,
92};
93#[cfg(feature = "tls-observation")]
94use super::tls_observation::WebAssessmentTlsObservationAudit;
95#[cfg(feature = "template-evaluation-review")]
96use super::web_review_execution::enabled_native_web_review_actions_with_template;
97#[cfg(feature = "websocket-review")]
98use super::websocket_runtime::{
99    execute_websocket_review, websocket_review_not_run, WebAssessmentWebSocketReviewAudit,
100    WebSocketReviewFailureKind,
101};
102#[cfg(feature = "wordpress-review")]
103use super::wordpress_fingerprint_runtime::{
104    WordPressAssetFingerprintCollector, WordPressAssetFingerprintStop,
105};
106#[cfg(feature = "wordpress-review")]
107use super::wordpress_runtime::{
108    extract_wordpress_entry_observation_with_asset_fingerprints,
109    extract_wordpress_page_observation_with_asset_fingerprints, CommittedWordPressReview,
110    FrozenWordPressPageLayout, WebAssessmentWordPressAudit, WordPressDiscoveryContext,
111    WordPressDiscoveryStop, WordPressPageCandidate, WordPressReviewBinding,
112    WordPressSignalCollector,
113};
114#[cfg(feature = "xml-external-entity-review")]
115use super::xml_external_entity_runtime::{
116    CommittedXmlExternalEntityReview, XmlExternalEntityRuntimeConfig,
117    XmlExternalEntityRuntimeResult,
118};
119use super::{
120    assessment_api_visibility::{
121        build_root_api_visibility_runtime, CommittedAssessmentApiVisibility,
122        RootApiVisibilityOutcome, RootApiVisibilityRuntime, WebAssessmentRootAuthorizationContext,
123    },
124    assessment_defense::{
125        AssessmentDefenseBodyCoverage, AssessmentDefenseController, ASSESSMENT_DEFENSE_NAMESPACE,
126    },
127    assessment_item::{AssessmentItem, AssessmentItemSet},
128    assessment_passive::{
129        project_assessment_items, project_assessment_passive_response,
130        AssessmentPassiveProjectionContext, AssessmentReviewProjectionSources,
131        CommittedAssessmentPassiveLedger, PassiveAssessmentProjectionIncompleteness,
132        ASSESSMENT_PASSIVE_NAMESPACE,
133    },
134    assessment_review::{AssessmentReviewObserverSet, CommittedAssessmentReviewLedger},
135    web_review_execution::enabled_native_web_review_actions,
136    NativeWebReviewSeeds, SharedWebRuntimeAuthority, StandardWebDecisionAssessmentFailureParts,
137    StandardWebDecisionAssessmentParts, BOOTSTRAP_ACTION_ID, BOOTSTRAP_CASE_ID,
138    BOOTSTRAP_HYPOTHESIS_ID,
139};
140#[cfg(feature = "reporting")]
141use super::{
142    assessment_report::{
143        AssessmentRunReport, AssessmentRunReportError, AssessmentRuntimeLimits,
144        CompletedWebAssessmentTruth,
145    },
146    scan_profile::ScanProfileV1,
147};
148#[cfg(feature = "xml-external-entity-owned-https-test-profile")]
149use crate::http_evidence::OwnedXmlHttpsTestTransportProfile;
150#[cfg(feature = "jwt-target-acceptance-review")]
151use crate::jwt_target_acceptance::{
152    JwtTargetAcceptanceAudit, JwtTargetAcceptancePolicy, MAX_JWT_TARGET_ACCEPTANCE_ACTIVE_REQUESTS,
153};
154#[cfg(feature = "recon-ct-provider")]
155use crate::recon_ct_provider::ReconCtProviderPolicy;
156#[cfg(feature = "ssrf-oast-review")]
157use crate::ssrf_oast_review::{
158    select_observed_query_candidate, SsrfOastAdminToken, SsrfOastReviewPolicy,
159    SsrfOastTerminalState,
160};
161#[cfg(all(feature = "websocket-review", feature = "supplied-session-review"))]
162use crate::supplied_session_review::SuppliedSessionCredentialMechanism;
163#[cfg(any(
164    all(feature = "wordpress-review", feature = "supplied-session-review"),
165    all(feature = "websocket-review", feature = "supplied-session-review")
166))]
167use crate::supplied_session_review::SuppliedSessionPolicyVersion;
168#[cfg(feature = "supplied-session-review")]
169use crate::supplied_session_review::{SuppliedSessionPolicy, SuppliedSessionRuntimeInput};
170#[cfg(feature = "template-evaluation-review")]
171use crate::template_evaluation_review::{
172    TemplateEvaluationReviewAudit, TemplateEvaluationReviewAuditFacts,
173    TemplateEvaluationReviewOutcome, TEMPLATE_EVALUATION_MAX_REQUESTS,
174    TEMPLATE_EVALUATION_SELECTED_CASES,
175};
176#[cfg(feature = "websocket-review")]
177use crate::websocket_review::WebSocketReviewPolicy;
178#[cfg(feature = "wordpress-review")]
179use crate::wordpress_review::WordPressReviewInputs;
180#[cfg(feature = "xml-external-entity-review")]
181use crate::xml_external_entity_review::{
182    XmlExternalEntityAdminToken, XmlExternalEntityReviewAudit, XmlExternalEntityReviewOutcome,
183    XmlExternalEntityReviewPolicy, XML_EXTERNAL_ENTITY_ACTIVE_VERIFICATIONS,
184};
185#[cfg(feature = "template-evaluation-review")]
186use crate::TransportDispatchOutcome;
187use crate::{
188    http_evidence::{
189        CompleteHttpResponseObservation, CompleteHttpResponseObserver,
190        ResponseBodyDigestDisposition,
191    },
192    web_actions::NativeWebReviewActionKind,
193    AttackPlan, DecisionEvidenceReceipt, DecisionExecutionFailureReceipt, DecisionExecutionStage,
194    DecisionLoopCommand, DecisionStopReason, DefensePosture, DefenseProduct, FingerprintConfidence,
195    HttpEvidenceError, HttpEvidencePolicy, HttpProbe, HttpProbeMethod, KnowledgeBase, LimitsError,
196    RuntimeApiVisibilityExecutionError, RuntimeBudget, RuntimeBudgetDimension,
197    RuntimeLimitExceeded, SemanticExtractionLimits, SemanticExtractionResult, ShadowPlanDelta,
198    StandardWebActionKind, StandardWebDecisionRuntime, StandardWebDecisionRuntimeError,
199    StandardWebDecisionRuntimeTurn, TransportDispatchAudit, HTTP_EVIDENCE_EXECUTOR_ID,
200    MAX_HTTP_BODY_LIMIT,
201};
202
203mod attribute_boundary_matcher;
204mod attribute_source_context;
205mod discovery;
206mod javascript_source_context;
207#[cfg(feature = "normalization-resilience")]
208mod normalization_transform_catalog;
209mod reflection_context;
210mod semantic;
211mod xss_probe_catalog;
212
213/// Keep child-future construction temporaries out of the assessment poll frame.
214/// The existing assessment still polls and cancels the same borrowed child;
215/// there is no spawned task, separate runtime or new accounting authority.
216#[inline(never)]
217fn boxed_child_analysis(
218    runtime: &mut StandardWebDecisionRuntime,
219) -> std::pin::Pin<
220    Box<
221        impl std::future::Future<
222                Output = Result<
223                    crate::StandardWebDecisionRunReport,
224                    StandardWebDecisionRuntimeError,
225                >,
226            > + '_,
227    >,
228> {
229    Box::pin(runtime.analyze())
230}
231
232// Construct selected optional children on their own synchronous frame. Passing
233// a future value here would still reserve its construction copies in analyze's
234// poll frame; the one-shot factory is called immediately and is never retained.
235#[inline(never)]
236fn boxed_optional_child<F: std::future::Future>(make: impl FnOnce() -> F) -> std::pin::Pin<Box<F>> {
237    Box::pin(make())
238}
239
240#[cfg(all(feature = "websocket-review", feature = "supplied-session-review"))]
241#[inline(never)]
242fn boxed_session_websocket<'a>(
243    session: SuppliedSessionRuntimeConfig,
244    authority: &'a SharedWebRuntimeAuthority,
245    websocket: &'a WebSocketReviewPolicy,
246) -> std::pin::Pin<
247    Box<
248        impl std::future::Future<
249                Output = (
250                    WebAssessmentSuppliedSessionAudit,
251                    WebAssessmentWebSocketReviewAudit,
252                ),
253            > + 'a,
254    >,
255> {
256    // An unselected optional child must not reserve its large future-construction
257    // copies in every assessment poll. Selection, ownership and epoch rules stay
258    // with the existing child; only its allocation boundary is moved.
259    Box::pin(session.execute_with_websocket(authority, websocket))
260}
261
262#[cfg(feature = "supplied-session-review")]
263#[inline(never)]
264fn boxed_session_review<'a>(
265    session: SuppliedSessionRuntimeConfig,
266    authority: &'a SharedWebRuntimeAuthority,
267    observer: Option<&'a dyn SuppliedSessionCommittedResourceObserver>,
268) -> std::pin::Pin<Box<impl std::future::Future<Output = WebAssessmentSuppliedSessionAudit> + 'a>> {
269    Box::pin(session.execute(authority, observer))
270}
271
272#[cfg(feature = "websocket-review")]
273#[inline(never)]
274fn boxed_websocket_review<'a>(
275    authority: &'a SharedWebRuntimeAuthority,
276    policy: &'a WebSocketReviewPolicy,
277) -> std::pin::Pin<Box<impl std::future::Future<Output = WebAssessmentWebSocketReviewAudit> + 'a>> {
278    Box::pin(execute_websocket_review(authority, policy))
279}
280
281pub(super) use attribute_boundary_matcher::{
282    match_exact_xss_attribute_boundary_document, ExactXssAttributeBoundaryMatch,
283};
284pub(super) use attribute_source_context::{
285    cross_validate_attribute_reflection_source, AttributeQuoteMode, AttributeSourceResult,
286};
287use discovery::{canonicalize_root, parse_document, ParsedDocument, ParsedForm, ParsedRoute};
288pub(super) use javascript_source_context::{
289    cross_validate_javascript_reflection_source, match_exact_xss_javascript_boundary_document,
290    validate_exact_xss_javascript_boundary_candidate, ExactJavaScriptBoundaryMatch,
291    JavaScriptScriptKind, JavaScriptSourceResult,
292};
293#[cfg(feature = "normalization-resilience")]
294pub(super) use normalization_transform_catalog::{
295    select_normalization_transform, NormalizationTransformRef, NormalizationTransformSelection,
296};
297#[cfg(all(feature = "normalization-resilience", test))]
298pub(super) use normalization_transform_catalog::{
299    NORMALIZATION_V1_MAX_CHILD_ACTIVE_VERIFICATIONS, NORMALIZATION_V1_MAX_CHILD_REQUESTS,
300};
301pub(super) use reflection_context::{
302    classify_exact_html_reflection, match_exact_xss_html_boundary_document,
303    validate_exact_xss_html_boundary_fragment, ExactHtmlReflectionContext, ExactXssBoundaryMatch,
304};
305use semantic::{assessment_semantic_limits, AssessmentSemanticEvidence};
306pub(super) use xss_probe_catalog::{select_xss_probe_families, XssProbeFamily, XssProbeSelection};
307
308/// Default maximum canonical subjects retained by one assessment.
309pub const DEFAULT_WEB_ASSESSMENT_MAX_SUBJECTS: usize = 64;
310/// Compiled maximum canonical subjects retained by one assessment.
311pub const HARD_MAX_WEB_ASSESSMENT_SUBJECTS: usize = 1_024;
312/// Default maximum discovery depth after the authorized root.
313pub const DEFAULT_WEB_ASSESSMENT_MAX_DEPTH: u16 = 2;
314/// Compiled maximum discovery depth after the authorized root.
315pub const HARD_MAX_WEB_ASSESSMENT_DEPTH: u16 = 16;
316/// Default maximum distinct route references retained from one document.
317pub const DEFAULT_WEB_ASSESSMENT_MAX_REFERENCES_PER_DOCUMENT: usize = 128;
318/// Compiled maximum distinct route references retained from one document.
319pub const HARD_MAX_WEB_ASSESSMENT_REFERENCES_PER_DOCUMENT: usize = 2_048;
320/// Default maximum byte length of one canonical query-free URL.
321pub const DEFAULT_WEB_ASSESSMENT_MAX_CANONICAL_URL_BYTES: usize = 8_192;
322/// Compiled maximum byte length of one canonical query-free URL.
323pub const HARD_MAX_WEB_ASSESSMENT_CANONICAL_URL_BYTES: usize = 8_192;
324/// Default maximum bytes charged once per distinct canonical URL identity.
325pub const DEFAULT_WEB_ASSESSMENT_MAX_RETAINED_URL_BYTES: usize = 512 * 1024;
326/// Compiled maximum bytes charged once per distinct canonical URL identity.
327pub const HARD_MAX_WEB_ASSESSMENT_RETAINED_URL_BYTES: usize = 8 * 1024 * 1024;
328/// Default maximum number of forms retained by one assessment.
329pub const DEFAULT_WEB_ASSESSMENT_MAX_FORMS: usize = 64;
330/// Compiled maximum number of forms retained by one assessment.
331pub const HARD_MAX_WEB_ASSESSMENT_FORMS: usize = 1_024;
332/// Default maximum distinct control names retained for one form.
333pub const DEFAULT_WEB_ASSESSMENT_MAX_CONTROLS_PER_FORM: usize = 64;
334/// Compiled maximum distinct control names retained for one form.
335pub const HARD_MAX_WEB_ASSESSMENT_CONTROLS_PER_FORM: usize = 256;
336/// Default maximum distinct query names retained for one route or form action.
337pub const DEFAULT_WEB_ASSESSMENT_MAX_QUERY_NAMES: usize = 64;
338/// Compiled maximum distinct query names retained for one route or form action.
339pub const HARD_MAX_WEB_ASSESSMENT_QUERY_NAMES: usize = 256;
340/// Default maximum broker-owned dispatches for one assessment.
341pub const DEFAULT_WEB_ASSESSMENT_MAX_TOTAL_REQUESTS: u32 = 256;
342/// Compiled request ceiling; this includes the later 10,000-request benchmark.
343pub const HARD_MAX_WEB_ASSESSMENT_TOTAL_REQUESTS: u32 = 10_000;
344/// Default per-response body retention ceiling.
345pub const DEFAULT_WEB_ASSESSMENT_MAX_RESPONSE_BODY_BYTES: usize = 256 * 1024;
346/// Compiled maximum retained bytes for one response body.
347pub const HARD_MAX_WEB_ASSESSMENT_RESPONSE_BODY_BYTES: usize = MAX_HTTP_BODY_LIMIT;
348/// Default cumulative response-byte ceiling.
349pub const DEFAULT_WEB_ASSESSMENT_MAX_TOTAL_RESPONSE_BYTES: u64 = 16 * 1024 * 1024;
350/// Compiled cumulative response-byte ceiling.
351pub const HARD_MAX_WEB_ASSESSMENT_TOTAL_RESPONSE_BYTES: u64 = 256 * 1024 * 1024;
352/// Default complete-assessment wall-clock ceiling.
353pub const DEFAULT_WEB_ASSESSMENT_MAX_WALL_TIME: Duration = Duration::from_secs(300);
354/// Compiled complete-assessment wall-clock ceiling.
355pub const HARD_MAX_WEB_ASSESSMENT_WALL_TIME: Duration = Duration::from_secs(3_600);
356/// Default maximum active verification dispatches: seven for the initial
357/// native review pass, one context-selected XSS structural family, and two for
358/// one explicitly supplied authorization pair.
359/// Callers may select a lower ceiling; exhaustion remains fail-closed.
360pub const DEFAULT_WEB_ASSESSMENT_MAX_ACTIVE_VERIFICATIONS: u16 = 10;
361/// Compiled maximum active verification dispatches.
362pub const HARD_MAX_WEB_ASSESSMENT_ACTIVE_VERIFICATIONS: u16 = 64;
363/// Exact feature-local allowance reserved by an explicitly enabled GraphQL child.
364#[cfg(feature = "graphql-review")]
365pub(crate) const GRAPHQL_REVIEW_ACTIVE_VERIFICATION_ALLOWANCE: u16 =
366    MAX_GRAPHQL_ACTIVE_VERIFICATIONS as u16;
367/// Exact feature-local allowance reserved by one resource authorization child.
368#[cfg(feature = "authorization-review")]
369pub(crate) const AUTHORIZATION_REVIEW_ACTIVE_VERIFICATION_ALLOWANCE: u16 =
370    MAX_AUTHORIZATION_REVIEW_ACTIVE_VERIFICATIONS as u16;
371/// Two additional active candidate legs in the selected root template review.
372#[cfg(feature = "template-evaluation-review")]
373pub(crate) const TEMPLATE_EVALUATION_ACTIVE_VERIFICATION_ALLOWANCE: u16 = 2;
374/// Assessment subjects execute sequentially under one shared authority.
375pub const WEB_ASSESSMENT_CONCURRENCY: usize = 1;
376
377/// Closed, non-secret parameter-name catalog eligible for the optional
378/// external-destination differential. Discovery may observe other names, but
379/// the review runtime never guesses that they carry navigation destinations.
380const REDIRECT_REVIEW_QUERY_PARAMETER_ALLOWLIST: [&str; 11] = [
381    "continue",
382    "dest",
383    "destination",
384    "next",
385    "redirect",
386    "redirect_to",
387    "redirect_uri",
388    "return",
389    "return_to",
390    "return_url",
391    "url",
392];
393
394fn select_redirect_review_query_parameter(names: &[String]) -> Option<String> {
395    names
396        .iter()
397        .find(|name| {
398            REDIRECT_REVIEW_QUERY_PARAMETER_ALLOWLIST
399                .binary_search(&name.as_str())
400                .is_ok()
401        })
402        .cloned()
403}
404
405fn select_sql_review_query_parameter(names: &[String]) -> Option<String> {
406    names
407        .iter()
408        .filter(|name| {
409            !name.is_empty()
410                && name.len() <= 64
411                && name.bytes().all(|byte| {
412                    byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'.' | b'_' | b'~')
413                })
414        })
415        .min()
416        .cloned()
417}
418
419fn select_ssti_review_query_parameter(names: &[String]) -> Option<String> {
420    select_sql_review_query_parameter(names)
421}
422
423fn select_reflection_review_query_parameter(names: &[String]) -> Option<String> {
424    select_sql_review_query_parameter(names)
425}
426
427pub(crate) const HARD_MAX_DISCOVERY_NAME_BYTES: usize =
428    SemanticExtractionLimits::HARD_MAX_ASSESSMENT_PARAMETER_NAME_BYTES;
429pub(crate) const HARD_MAX_DISCOVERY_NAMES_PER_REFERENCE: usize =
430    SemanticExtractionLimits::HARD_MAX_ASSESSMENT_PARAMETER_NAMES_PER_REFERENCE;
431
432/// Invalid configuration for a bounded web assessment.
433#[derive(Debug, Clone, PartialEq, Eq, Error)]
434#[non_exhaustive]
435pub enum WebAssessmentLimitsError {
436    /// A dimension required to retain the authorized root was configured as zero.
437    #[error("web assessment limit {dimension} must be greater than zero")]
438    ZeroRequired { dimension: &'static str },
439    /// A selected limit exceeded its compiled ceiling.
440    #[error("web assessment limit {dimension}={actual} exceeds compiled maximum {maximum}")]
441    AboveHardMaximum {
442        dimension: &'static str,
443        actual: u64,
444        maximum: u64,
445    },
446}
447
448/// Checked resource and discovery envelope for one origin assessment.
449#[derive(Debug, Clone, Copy, PartialEq, Eq)]
450pub struct WebAssessmentLimits {
451    max_subjects: usize,
452    max_discovery_depth: u16,
453    max_references_per_document: usize,
454    max_canonical_url_bytes: usize,
455    max_retained_url_bytes: usize,
456    max_forms: usize,
457    max_controls_per_form: usize,
458    max_query_parameter_names: usize,
459    max_total_requests: u32,
460    max_response_body_bytes: usize,
461    max_total_response_bytes: u64,
462    max_wall_time: Duration,
463    max_active_verifications: u16,
464}
465
466impl WebAssessmentLimits {
467    /// Replaces the subject ceiling.
468    pub fn with_max_subjects(mut self, value: usize) -> Result<Self, WebAssessmentLimitsError> {
469        check_nonzero("max_subjects", value)?;
470        check_max("max_subjects", value, HARD_MAX_WEB_ASSESSMENT_SUBJECTS)?;
471        self.max_subjects = value;
472        Ok(self)
473    }
474
475    /// Replaces the maximum discovery depth. Zero permits only the root.
476    pub fn with_max_discovery_depth(
477        mut self,
478        value: u16,
479    ) -> Result<Self, WebAssessmentLimitsError> {
480        check_max("max_discovery_depth", value, HARD_MAX_WEB_ASSESSMENT_DEPTH)?;
481        self.max_discovery_depth = value;
482        Ok(self)
483    }
484
485    /// Replaces the per-document route-reference ceiling. Zero retains none.
486    pub fn with_max_references_per_document(
487        mut self,
488        value: usize,
489    ) -> Result<Self, WebAssessmentLimitsError> {
490        check_max(
491            "max_references_per_document",
492            value,
493            HARD_MAX_WEB_ASSESSMENT_REFERENCES_PER_DOCUMENT,
494        )?;
495        self.max_references_per_document = value;
496        Ok(self)
497    }
498
499    /// Replaces the canonical URL byte ceiling.
500    pub fn with_max_canonical_url_bytes(
501        mut self,
502        value: usize,
503    ) -> Result<Self, WebAssessmentLimitsError> {
504        check_nonzero("max_canonical_url_bytes", value)?;
505        check_max(
506            "max_canonical_url_bytes",
507            value,
508            HARD_MAX_WEB_ASSESSMENT_CANONICAL_URL_BYTES,
509        )?;
510        self.max_canonical_url_bytes = value;
511        Ok(self)
512    }
513
514    /// Replaces the distinct canonical-URL identity byte ceiling.
515    pub fn with_max_retained_url_bytes(
516        mut self,
517        value: usize,
518    ) -> Result<Self, WebAssessmentLimitsError> {
519        check_nonzero("max_retained_url_bytes", value)?;
520        check_max(
521            "max_retained_url_bytes",
522            value,
523            HARD_MAX_WEB_ASSESSMENT_RETAINED_URL_BYTES,
524        )?;
525        self.max_retained_url_bytes = value;
526        Ok(self)
527    }
528
529    /// Replaces the total form ceiling. Zero retains no forms.
530    pub fn with_max_forms(mut self, value: usize) -> Result<Self, WebAssessmentLimitsError> {
531        check_max("max_forms", value, HARD_MAX_WEB_ASSESSMENT_FORMS)?;
532        self.max_forms = value;
533        Ok(self)
534    }
535
536    /// Replaces the per-form control-name ceiling. Zero retains no names.
537    pub fn with_max_controls_per_form(
538        mut self,
539        value: usize,
540    ) -> Result<Self, WebAssessmentLimitsError> {
541        check_max(
542            "max_controls_per_form",
543            value,
544            HARD_MAX_WEB_ASSESSMENT_CONTROLS_PER_FORM,
545        )?;
546        self.max_controls_per_form = value;
547        Ok(self)
548    }
549
550    /// Replaces the query-name ceiling per route or form. Zero retains none.
551    pub fn with_max_query_parameter_names(
552        mut self,
553        value: usize,
554    ) -> Result<Self, WebAssessmentLimitsError> {
555        check_max(
556            "max_query_parameter_names",
557            value,
558            HARD_MAX_WEB_ASSESSMENT_QUERY_NAMES,
559        )?;
560        self.max_query_parameter_names = value;
561        Ok(self)
562    }
563
564    /// Replaces the authority-wide request ceiling. Zero denies all dispatches.
565    pub fn with_max_total_requests(mut self, value: u32) -> Result<Self, WebAssessmentLimitsError> {
566        check_max(
567            "max_total_requests",
568            value,
569            HARD_MAX_WEB_ASSESSMENT_TOTAL_REQUESTS,
570        )?;
571        self.max_total_requests = value;
572        Ok(self)
573    }
574
575    /// Replaces the per-response body ceiling.
576    pub fn with_max_response_body_bytes(
577        mut self,
578        value: usize,
579    ) -> Result<Self, WebAssessmentLimitsError> {
580        check_nonzero("max_response_body_bytes", value)?;
581        check_max("max_response_body_bytes", value, MAX_HTTP_BODY_LIMIT)?;
582        self.max_response_body_bytes = value;
583        Ok(self)
584    }
585
586    /// Replaces the authority-wide response-byte ceiling.
587    pub fn with_max_total_response_bytes(
588        mut self,
589        value: u64,
590    ) -> Result<Self, WebAssessmentLimitsError> {
591        if value == 0 {
592            return Err(WebAssessmentLimitsError::ZeroRequired {
593                dimension: "max_total_response_bytes",
594            });
595        }
596        check_max(
597            "max_total_response_bytes",
598            value,
599            HARD_MAX_WEB_ASSESSMENT_TOTAL_RESPONSE_BYTES,
600        )?;
601        self.max_total_response_bytes = value;
602        Ok(self)
603    }
604
605    /// Replaces the authority-wide wall-clock ceiling. Zero denies execution.
606    pub fn with_max_wall_time(mut self, value: Duration) -> Result<Self, WebAssessmentLimitsError> {
607        if value > HARD_MAX_WEB_ASSESSMENT_WALL_TIME {
608            return Err(WebAssessmentLimitsError::AboveHardMaximum {
609                dimension: "max_wall_time_ms",
610                actual: duration_ms(value),
611                maximum: duration_ms(HARD_MAX_WEB_ASSESSMENT_WALL_TIME),
612            });
613        }
614        self.max_wall_time = value;
615        Ok(self)
616    }
617
618    /// Replaces the active-verification ceiling. Zero denies active requests.
619    pub fn with_max_active_verifications(
620        mut self,
621        value: u16,
622    ) -> Result<Self, WebAssessmentLimitsError> {
623        check_max(
624            "max_active_verifications",
625            value,
626            HARD_MAX_WEB_ASSESSMENT_ACTIVE_VERIFICATIONS,
627        )?;
628        self.max_active_verifications = value;
629        Ok(self)
630    }
631
632    /// Returns the subject ceiling, including the root.
633    pub const fn max_subjects(self) -> usize {
634        self.max_subjects
635    }
636    /// Returns the maximum depth after the root.
637    pub const fn max_discovery_depth(self) -> u16 {
638        self.max_discovery_depth
639    }
640    /// Returns the route-reference ceiling per document.
641    pub const fn max_references_per_document(self) -> usize {
642        self.max_references_per_document
643    }
644    /// Returns the canonical URL byte ceiling.
645    pub const fn max_canonical_url_bytes(self) -> usize {
646        self.max_canonical_url_bytes
647    }
648    /// Returns the byte ceiling charged once per distinct canonical URL.
649    pub const fn max_retained_url_bytes(self) -> usize {
650        self.max_retained_url_bytes
651    }
652    /// Returns the assessment-wide form ceiling.
653    pub const fn max_forms(self) -> usize {
654        self.max_forms
655    }
656    /// Returns the control-name ceiling per form.
657    pub const fn max_controls_per_form(self) -> usize {
658        self.max_controls_per_form
659    }
660    /// Returns the query-name ceiling per reference.
661    pub const fn max_query_parameter_names(self) -> usize {
662        self.max_query_parameter_names
663    }
664    /// Returns the authority-wide request ceiling.
665    pub const fn max_total_requests(self) -> u32 {
666        self.max_total_requests
667    }
668    /// Returns the per-response body ceiling.
669    pub const fn max_response_body_bytes(self) -> usize {
670        self.max_response_body_bytes
671    }
672    /// Returns the authority-wide response-byte ceiling.
673    pub const fn max_total_response_bytes(self) -> u64 {
674        self.max_total_response_bytes
675    }
676    /// Returns the authority-wide wall-clock ceiling.
677    pub const fn max_wall_time(self) -> Duration {
678        self.max_wall_time
679    }
680    /// Returns the authority-wide active-verification ceiling.
681    pub const fn max_active_verifications(self) -> u16 {
682        self.max_active_verifications
683    }
684    /// Returns the fixed concurrency boundary.
685    pub const fn concurrency(self) -> usize {
686        WEB_ASSESSMENT_CONCURRENCY
687    }
688
689    // GET/HEAD discovery sends no request body. The shared authority keeps the
690    // existing RuntimeBudget defaults for request-body bytes (256 KiB),
691    // same-action attempts (3), and consecutive no-progress turns (4); this
692    // assessment contract narrows only the explicitly exposed dimensions.
693    fn runtime_budget(self, optional_active_verifications: u16) -> RuntimeBudget {
694        let active_verifications = self
695            .max_active_verifications
696            .checked_add(optional_active_verifications)
697            .expect("compiled optional active-verification allowances fit u16");
698        RuntimeBudget::default()
699            .with_max_total_requests(self.max_total_requests)
700            .with_max_response_bytes(self.max_total_response_bytes)
701            .with_max_wall_time(self.max_wall_time)
702            .with_max_active_verifications(active_verifications)
703    }
704}
705
706impl Default for WebAssessmentLimits {
707    fn default() -> Self {
708        Self {
709            max_subjects: DEFAULT_WEB_ASSESSMENT_MAX_SUBJECTS,
710            max_discovery_depth: DEFAULT_WEB_ASSESSMENT_MAX_DEPTH,
711            max_references_per_document: DEFAULT_WEB_ASSESSMENT_MAX_REFERENCES_PER_DOCUMENT,
712            max_canonical_url_bytes: DEFAULT_WEB_ASSESSMENT_MAX_CANONICAL_URL_BYTES,
713            max_retained_url_bytes: DEFAULT_WEB_ASSESSMENT_MAX_RETAINED_URL_BYTES,
714            max_forms: DEFAULT_WEB_ASSESSMENT_MAX_FORMS,
715            max_controls_per_form: DEFAULT_WEB_ASSESSMENT_MAX_CONTROLS_PER_FORM,
716            max_query_parameter_names: DEFAULT_WEB_ASSESSMENT_MAX_QUERY_NAMES,
717            max_total_requests: DEFAULT_WEB_ASSESSMENT_MAX_TOTAL_REQUESTS,
718            max_response_body_bytes: DEFAULT_WEB_ASSESSMENT_MAX_RESPONSE_BODY_BYTES,
719            max_total_response_bytes: DEFAULT_WEB_ASSESSMENT_MAX_TOTAL_RESPONSE_BYTES,
720            max_wall_time: DEFAULT_WEB_ASSESSMENT_MAX_WALL_TIME,
721            max_active_verifications: DEFAULT_WEB_ASSESSMENT_MAX_ACTIVE_VERIFICATIONS,
722        }
723    }
724}
725
726fn check_nonzero(dimension: &'static str, value: usize) -> Result<(), WebAssessmentLimitsError> {
727    if value == 0 {
728        Err(WebAssessmentLimitsError::ZeroRequired { dimension })
729    } else {
730        Ok(())
731    }
732}
733
734fn check_max<T>(
735    dimension: &'static str,
736    value: T,
737    maximum: T,
738) -> Result<(), WebAssessmentLimitsError>
739where
740    T: Copy + Ord + TryInto<u64>,
741{
742    if value > maximum {
743        Err(WebAssessmentLimitsError::AboveHardMaximum {
744            dimension,
745            actual: value.try_into().unwrap_or(u64::MAX),
746            maximum: maximum.try_into().unwrap_or(u64::MAX),
747        })
748    } else {
749        Ok(())
750    }
751}
752
753fn duration_ms(duration: Duration) -> u64 {
754    u64::try_from(duration.as_millis()).unwrap_or(u64::MAX)
755}
756
757/// Safe method boundary retained for an assessment subject.
758#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
759#[non_exhaustive]
760pub enum WebAssessmentMethod {
761    /// Retrieve a complete bounded representation for semantic review.
762    Get,
763    /// Observe response metadata only.
764    Head,
765}
766
767impl WebAssessmentMethod {
768    const fn probe_method(self) -> HttpProbeMethod {
769        match self {
770            Self::Get => HttpProbeMethod::Get,
771            Self::Head => HttpProbeMethod::Head,
772        }
773    }
774}
775
776/// HTML form method retained without dispatching POST or dialog actions.
777#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
778#[non_exhaustive]
779pub enum WebAssessmentFormMethod {
780    /// A safe GET action candidate.
781    Get,
782    /// A recorded POST boundary that discovery never dispatches.
783    Post,
784    /// A recorded dialog boundary that discovery never dispatches.
785    Dialog,
786}
787
788/// Provenance class for one canonical subject.
789#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
790#[non_exhaustive]
791pub enum WebAssessmentSubjectOrigin {
792    /// Query-free root explicitly authorized by the host.
793    AuthorizedRoot,
794    /// Same-origin subject derived from committed HTML discovery evidence.
795    Discovered,
796}
797
798/// One canonical query-free assessment subject.
799#[derive(Clone, PartialEq, Eq)]
800pub struct WebAssessmentSubject {
801    url: Url,
802    method: WebAssessmentMethod,
803    depth: u16,
804    origin: WebAssessmentSubjectOrigin,
805    query_parameter_names: Vec<String>,
806    evidence_ids: Vec<EvidenceId>,
807}
808
809impl fmt::Debug for WebAssessmentSubject {
810    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
811        formatter
812            .debug_struct("WebAssessmentSubject")
813            .field("url", &"<redacted>")
814            .field("method", &self.method)
815            .field("depth", &self.depth)
816            .field("origin", &self.origin)
817            .field("query_parameter_count", &self.query_parameter_names.len())
818            .field("evidence_count", &self.evidence_ids.len())
819            .finish()
820    }
821}
822
823impl WebAssessmentSubject {
824    /// Returns the canonical query-free URL.
825    pub fn url(&self) -> &Url {
826        &self.url
827    }
828    /// Returns the safe method boundary.
829    pub const fn method(&self) -> WebAssessmentMethod {
830        self.method
831    }
832    /// Returns the stable BFS depth, with the root at zero.
833    pub const fn depth(&self) -> u16 {
834        self.depth
835    }
836    /// Returns whether the subject was host-authorized or discovered.
837    pub const fn origin(&self) -> WebAssessmentSubjectOrigin {
838        self.origin
839    }
840    /// Returns sorted, de-duplicated candidate query names. No values exist.
841    pub fn query_parameter_names(&self) -> &[String] {
842        &self.query_parameter_names
843    }
844    /// Returns committed discovery evidence identities for this subject.
845    pub fn evidence_ids(&self) -> &[EvidenceId] {
846        &self.evidence_ids
847    }
848}
849
850/// One canonical same-origin form observation.
851#[derive(Clone, PartialEq, Eq)]
852pub struct WebAssessmentForm {
853    document_url: Url,
854    action: Url,
855    method: WebAssessmentFormMethod,
856    query_parameter_names: Vec<String>,
857    control_names: Vec<String>,
858    evidence_ids: Vec<EvidenceId>,
859}
860
861impl fmt::Debug for WebAssessmentForm {
862    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
863        formatter
864            .debug_struct("WebAssessmentForm")
865            .field("document_url", &"<redacted>")
866            .field("action", &"<redacted>")
867            .field("method", &self.method)
868            .field("query_parameter_count", &self.query_parameter_names.len())
869            .field("control_count", &self.control_names.len())
870            .field("evidence_count", &self.evidence_ids.len())
871            .finish()
872    }
873}
874
875impl WebAssessmentForm {
876    /// Returns the query-free document that contained the form.
877    pub fn document_url(&self) -> &Url {
878        &self.document_url
879    }
880    /// Returns the canonical query-free action.
881    pub fn action(&self) -> &Url {
882        &self.action
883    }
884    /// Returns the observed form method.
885    pub const fn method(&self) -> WebAssessmentFormMethod {
886        self.method
887    }
888    /// Returns sorted candidate action query names. No values exist.
889    pub fn query_parameter_names(&self) -> &[String] {
890        &self.query_parameter_names
891    }
892    /// Returns sorted control names. No control values exist.
893    pub fn control_names(&self) -> &[String] {
894        &self.control_names
895    }
896    /// Returns committed discovery evidence identities for this form.
897    pub fn evidence_ids(&self) -> &[EvidenceId] {
898        &self.evidence_ids
899    }
900}
901
902/// One subject's standard decision audit without duplicated global accounting.
903pub struct WebAssessmentSubjectReport {
904    subject: WebAssessmentSubject,
905    executed: bool,
906    bootstrap: Option<DecisionEvidenceReceipt>,
907    turns: Vec<StandardWebDecisionRuntimeTurn>,
908    unverified_evidence: Option<DecisionEvidenceReceipt>,
909    terminal: Option<DecisionLoopCommand>,
910    limit_exceeded: Option<RuntimeLimitExceeded>,
911    execution_failure: Option<DecisionExecutionFailureReceipt>,
912}
913
914impl fmt::Debug for WebAssessmentSubjectReport {
915    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
916        formatter
917            .debug_struct("WebAssessmentSubjectReport")
918            .field("subject", &self.subject)
919            .field("executed", &self.executed)
920            .field("bootstrap_committed", &self.bootstrap.is_some())
921            .field("turn_count", &self.turns.len())
922            .field(
923                "unverified_evidence_committed",
924                &self.unverified_evidence.is_some(),
925            )
926            .field("terminal_present", &self.terminal.is_some())
927            .field("limit_exceeded", &self.limit_exceeded.is_some())
928            .field("execution_failed", &self.execution_failure.is_some())
929            .finish()
930    }
931}
932
933impl WebAssessmentSubjectReport {
934    fn pending(subject: WebAssessmentSubject) -> Self {
935        Self {
936            subject,
937            executed: false,
938            bootstrap: None,
939            turns: Vec::new(),
940            unverified_evidence: None,
941            terminal: None,
942            limit_exceeded: None,
943            execution_failure: None,
944        }
945    }
946
947    fn complete(subject: WebAssessmentSubject, parts: StandardWebDecisionAssessmentParts) -> Self {
948        Self {
949            subject,
950            executed: true,
951            bootstrap: parts.bootstrap,
952            turns: parts.turns,
953            unverified_evidence: parts.unverified_evidence,
954            terminal: Some(parts.terminal),
955            limit_exceeded: parts.limit_exceeded,
956            execution_failure: parts.execution_failure,
957        }
958    }
959
960    fn failed(
961        subject: WebAssessmentSubject,
962        parts: StandardWebDecisionAssessmentFailureParts,
963        executed: bool,
964    ) -> Self {
965        Self {
966            subject,
967            executed,
968            bootstrap: parts.bootstrap,
969            turns: parts.turns,
970            unverified_evidence: None,
971            terminal: None,
972            limit_exceeded: None,
973            execution_failure: None,
974        }
975    }
976
977    /// Returns the canonical subject.
978    pub fn subject(&self) -> &WebAssessmentSubject {
979        &self.subject
980    }
981    /// Returns whether this retained subject reached the standard runtime.
982    pub fn was_executed(&self) -> bool {
983        self.executed
984    }
985    /// Returns the committed bootstrap evidence when execution started it.
986    pub fn bootstrap(&self) -> Option<&DecisionEvidenceReceipt> {
987        self.bootstrap.as_ref()
988    }
989    /// Returns planning and outcome turns without a nested transport snapshot.
990    pub fn turns(&self) -> &[StandardWebDecisionRuntimeTurn] {
991        &self.turns
992    }
993    /// Returns evidence committed before verification was skipped.
994    pub fn unverified_evidence(&self) -> Option<&DecisionEvidenceReceipt> {
995        self.unverified_evidence.as_ref()
996    }
997    /// Returns the subject terminal, or `None` when global limits prevented it.
998    pub fn terminal(&self) -> Option<&DecisionLoopCommand> {
999        self.terminal.as_ref()
1000    }
1001    /// Returns the subject-local view of a global runtime limit.
1002    pub fn limit_exceeded(&self) -> Option<&RuntimeLimitExceeded> {
1003        self.limit_exceeded.as_ref()
1004    }
1005    /// Returns the executor failure retained in a limit report.
1006    pub fn execution_failure(&self) -> Option<&DecisionExecutionFailureReceipt> {
1007        self.execution_failure.as_ref()
1008    }
1009}
1010
1011/// Aggregate monotonic usage for one assessment authority.
1012#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
1013pub struct WebAssessmentUsage {
1014    retained_subjects: usize,
1015    executed_subjects: usize,
1016    retained_forms: usize,
1017    retained_unique_url_bytes: usize,
1018    total_requests: u32,
1019    active_verifications: u16,
1020    request_body_bytes: u64,
1021    response_bytes: u64,
1022    elapsed_ms: u64,
1023}
1024
1025/// Coarse orchestration boundary associated with a live assessment snapshot.
1026#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
1027#[non_exhaustive]
1028pub enum WebAssessmentProgressCheckpoint {
1029    AssessmentStarted,
1030    SubjectExecutionStarted,
1031    SubjectStateCommitted,
1032    InventoryReconciled,
1033    ProjectionStarted,
1034    FinishedSnapshot,
1035}
1036
1037/// A safe, last-observed view of live assessment accounting.
1038///
1039/// Request and active-verification counts come from the same parent broker
1040/// that enforces the assessment budget. Subject counts are published only at
1041/// general orchestration boundaries. The snapshot is presentation data: it
1042/// carries no target, evidence, planner, budget, or execution authority.
1043#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1044pub struct WebAssessmentProgressSnapshot {
1045    checkpoint: WebAssessmentProgressCheckpoint,
1046    executed_subjects: usize,
1047    subjects_processed: usize,
1048    requests_accounted: u32,
1049    active_verifications_accounted: u16,
1050}
1051
1052impl WebAssessmentProgressSnapshot {
1053    /// Returns the general orchestration boundary observed by this snapshot.
1054    pub const fn checkpoint(self) -> WebAssessmentProgressCheckpoint {
1055        self.checkpoint
1056    }
1057
1058    /// Returns subjects that reached the child-runtime execution boundary.
1059    pub const fn executed_subjects(self) -> usize {
1060        self.executed_subjects
1061    }
1062
1063    /// Returns executed subjects whose child result reached report projection.
1064    pub const fn subjects_processed(self) -> usize {
1065        self.subjects_processed
1066    }
1067
1068    /// Returns cumulative requests admitted and charged by the parent broker.
1069    pub const fn requests_accounted(self) -> u32 {
1070        self.requests_accounted
1071    }
1072
1073    /// Returns cumulative active-verification dispatches charged by the parent
1074    /// broker. This is not a count of concurrently in-flight requests.
1075    pub const fn active_verifications_accounted(self) -> u16 {
1076        self.active_verifications_accounted
1077    }
1078}
1079
1080/// Read-only live observation handle for one opted-in assessment.
1081///
1082/// The handle can only read a coalesced scalar snapshot. It cannot mutate the
1083/// runtime, select work, dispatch requests, or influence completion. A missing
1084/// snapshot means no orchestration checkpoint has yet been published.
1085pub struct WebAssessmentProgressObserver {
1086    receiver: watch::Receiver<Option<WebAssessmentProgressSnapshot>>,
1087}
1088
1089impl WebAssessmentProgressObserver {
1090    /// Returns the latest safe snapshot without async or network work.
1091    pub fn latest(&self) -> Option<WebAssessmentProgressSnapshot> {
1092        *self.receiver.borrow()
1093    }
1094}
1095
1096impl WebAssessmentUsage {
1097    pub const fn retained_subjects(self) -> usize {
1098        self.retained_subjects
1099    }
1100    pub const fn executed_subjects(self) -> usize {
1101        self.executed_subjects
1102    }
1103    pub const fn retained_forms(self) -> usize {
1104        self.retained_forms
1105    }
1106    /// Returns bytes charged once per distinct canonical URL identity.
1107    pub const fn retained_unique_url_bytes(self) -> usize {
1108        self.retained_unique_url_bytes
1109    }
1110    pub const fn total_requests(self) -> u32 {
1111        self.total_requests
1112    }
1113    pub const fn active_verifications(self) -> u16 {
1114        self.active_verifications
1115    }
1116    pub const fn request_body_bytes(self) -> u64 {
1117        self.request_body_bytes
1118    }
1119    pub const fn response_bytes(self) -> u64 {
1120        self.response_bytes
1121    }
1122    pub const fn elapsed_ms(self) -> u64 {
1123        self.elapsed_ms
1124    }
1125}
1126
1127/// A bounded condition that prevented exhaustive assessment execution.
1128#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
1129#[non_exhaustive]
1130pub enum WebAssessmentIncompleteReason {
1131    SubjectLimit,
1132    DiscoveryDepthLimit,
1133    DocumentReferenceLimit,
1134    CanonicalUrlBytesLimit,
1135    RetainedUrlBytesLimit,
1136    FormLimit,
1137    FormControlLimit,
1138    QueryParameterNameLimit,
1139    ResponseBodyIncomplete,
1140    PartialRepresentation,
1141    InvalidUtf8,
1142    TotalRequestLimit,
1143    ResponseBytesLimit,
1144    RequestBodyBytesLimit,
1145    WallTimeLimit,
1146    ActiveVerificationLimit,
1147    SameActionAttemptLimit,
1148    ConsecutiveNoProgressLimit,
1149    ActionCycleLimit,
1150    AdaptationLimit,
1151    HumanReviewRequired,
1152    SubjectExecutionIncomplete,
1153    HostCancellation,
1154    SemanticExtractionLimit,
1155    PassiveResponseProjectionLimit,
1156    AssessmentSubjectIdentityUnavailable,
1157    /// The explicitly enabled matched review catalog did not commit every
1158    /// control/candidate observation required by its configured boundary.
1159    DifferentialReviewIncomplete,
1160    /// The explicitly supplied authorization-context pair did not produce one
1161    /// complete canonical comparison suitable for product projection.
1162    ApiVisibilityReviewIncomplete,
1163    /// The explicitly enabled anonymous GraphQL child did not complete its
1164    /// bounded correlated protocol review.
1165    #[cfg(feature = "graphql-review")]
1166    GraphqlReviewIncomplete,
1167    /// The explicitly enabled four-view resource authorization child did not
1168    /// complete its bounded comparison.
1169    #[cfg(feature = "authorization-review")]
1170    AuthorizationReviewIncomplete,
1171    /// The explicitly enabled OpenAPI document/replay pair did not complete.
1172    #[cfg(feature = "openapi-review")]
1173    OpenApiReviewIncomplete,
1174    /// The explicitly enabled OpenAPI-constrained REST candidate/replay pair
1175    /// did not complete its bounded anonymous comparison.
1176    #[cfg(feature = "rest-review")]
1177    RestReviewIncomplete,
1178    /// The explicitly enabled query-only SSRF/OAST child did not complete its
1179    /// bounded correlated target and provider lifecycle.
1180    #[cfg(feature = "ssrf-oast-review")]
1181    SsrfOastReviewIncomplete,
1182    /// The explicitly enabled controlled XML child did not complete its fixed
1183    /// target/provider lifecycle and reconciled cleanup/accounting contract.
1184    #[cfg(feature = "xml-external-entity-review")]
1185    XmlExternalEntityReviewIncomplete,
1186    /// The explicitly selected active TLS matrix could not attempt every
1187    /// network-capable row within its parent/evidence/cancellation/deadline
1188    /// boundary.
1189    #[cfg(feature = "tls-negotiation-review")]
1190    TlsNegotiationReviewIncomplete,
1191    /// The explicitly enabled transport-free WordPress interpretation could
1192    /// not retain or evaluate its bounded input completely.
1193    #[cfg(feature = "wordpress-review")]
1194    WordPressReviewIncomplete,
1195}
1196
1197/// Whether every retained subject and eligible document completed within bounds.
1198#[derive(Debug, Clone, PartialEq, Eq)]
1199#[non_exhaustive]
1200pub enum WebAssessmentCompletion {
1201    Complete,
1202    Incomplete {
1203        reasons: BTreeSet<WebAssessmentIncompleteReason>,
1204    },
1205}
1206
1207impl WebAssessmentCompletion {
1208    /// Returns the typed incomplete reasons, or an empty set for a complete run.
1209    pub fn reasons(&self) -> BTreeSet<WebAssessmentIncompleteReason> {
1210        match self {
1211            Self::Complete => BTreeSet::new(),
1212            Self::Incomplete { reasons } => reasons.clone(),
1213        }
1214    }
1215}
1216
1217/// Bounded body coverage used for a defense observation. This describes only
1218/// the detector input; it never claims exhaustive response-body analysis.
1219#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1220pub enum WebAssessmentDefenseBodyCoverage {
1221    MetadataOnly,
1222    CompleteUtf8Prefix,
1223}
1224
1225/// One committed confidence-graded defense observation. A fingerprint is a
1226/// product hint only, never a categorical WAF-presence claim.
1227#[derive(Clone, PartialEq, Eq)]
1228pub struct WebAssessmentDefenseObservation {
1229    subject: termivar_core::EntityId,
1230    case_id: String,
1231    stage: DecisionExecutionStage,
1232    status: u16,
1233    posture: Option<DefensePosture>,
1234    challenge_observed: bool,
1235    rate_limit_observed: bool,
1236    fingerprint_hint: Option<(DefenseProduct, FingerprintConfidence)>,
1237    body_coverage: WebAssessmentDefenseBodyCoverage,
1238    input_limit_reached: bool,
1239    evidence_ids: Vec<EvidenceId>,
1240}
1241
1242impl fmt::Debug for WebAssessmentDefenseObservation {
1243    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
1244        formatter
1245            .debug_struct("WebAssessmentDefenseObservation")
1246            .field("subject", &"<redacted>")
1247            .field("case_id", &"<redacted>")
1248            .field("stage", &self.stage)
1249            .field("status", &self.status)
1250            .field("posture", &self.posture)
1251            .field("challenge_observed", &self.challenge_observed)
1252            .field("rate_limit_observed", &self.rate_limit_observed)
1253            .field("fingerprint_hint", &self.fingerprint_hint)
1254            .field("body_coverage", &self.body_coverage)
1255            .field("input_limit_reached", &self.input_limit_reached)
1256            .field("evidence_count", &self.evidence_ids.len())
1257            .finish()
1258    }
1259}
1260
1261impl WebAssessmentDefenseObservation {
1262    pub fn subject(&self) -> &termivar_core::EntityId {
1263        &self.subject
1264    }
1265    pub fn case_id(&self) -> &str {
1266        &self.case_id
1267    }
1268    pub const fn stage(&self) -> DecisionExecutionStage {
1269        self.stage
1270    }
1271    pub const fn status(&self) -> u16 {
1272        self.status
1273    }
1274    /// Returns the bounded detector posture. `Some(Open)` means only that the
1275    /// complete, uncapped UTF-8 detector prefix carried no positive signal; it
1276    /// never means that a WAF or other defense is absent. Metadata-only and
1277    /// capped Open observations return `None`.
1278    pub const fn posture(&self) -> Option<DefensePosture> {
1279        self.posture
1280    }
1281    pub const fn challenge_observed(&self) -> bool {
1282        self.challenge_observed
1283    }
1284    pub const fn rate_limit_observed(&self) -> bool {
1285        self.rate_limit_observed
1286    }
1287    pub const fn fingerprint_hint(&self) -> Option<(DefenseProduct, FingerprintConfidence)> {
1288        self.fingerprint_hint
1289    }
1290    pub const fn body_coverage(&self) -> WebAssessmentDefenseBodyCoverage {
1291        self.body_coverage
1292    }
1293    pub const fn input_limit_reached(&self) -> bool {
1294        self.input_limit_reached
1295    }
1296    pub fn evidence_ids(&self) -> &[EvidenceId] {
1297        &self.evidence_ids
1298    }
1299}
1300
1301/// Positive-only matched control/candidate defense delta.
1302#[derive(Clone, PartialEq, Eq)]
1303pub struct WebAssessmentDefenseTransition {
1304    case_id: String,
1305    candidate_block_status_appeared: bool,
1306    newly_rate_limited: bool,
1307    candidate_fingerprint_hint: Option<(DefenseProduct, FingerprintConfidence)>,
1308    control_evidence_ids: Vec<EvidenceId>,
1309    candidate_evidence_ids: Vec<EvidenceId>,
1310}
1311
1312impl fmt::Debug for WebAssessmentDefenseTransition {
1313    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
1314        formatter
1315            .debug_struct("WebAssessmentDefenseTransition")
1316            .field("case_id", &"<redacted>")
1317            .field(
1318                "candidate_block_status_appeared",
1319                &self.candidate_block_status_appeared,
1320            )
1321            .field("newly_rate_limited", &self.newly_rate_limited)
1322            .field(
1323                "candidate_fingerprint_hint",
1324                &self.candidate_fingerprint_hint,
1325            )
1326            .field("control_evidence_count", &self.control_evidence_ids.len())
1327            .field(
1328                "candidate_evidence_count",
1329                &self.candidate_evidence_ids.len(),
1330            )
1331            .finish()
1332    }
1333}
1334
1335impl WebAssessmentDefenseTransition {
1336    pub fn case_id(&self) -> &str {
1337        &self.case_id
1338    }
1339    pub const fn candidate_block_status_appeared(&self) -> bool {
1340        self.candidate_block_status_appeared
1341    }
1342    pub const fn newly_rate_limited(&self) -> bool {
1343        self.newly_rate_limited
1344    }
1345    pub const fn candidate_fingerprint_hint(
1346        &self,
1347    ) -> Option<(DefenseProduct, FingerprintConfidence)> {
1348        self.candidate_fingerprint_hint
1349    }
1350    pub fn control_evidence_ids(&self) -> &[EvidenceId] {
1351        &self.control_evidence_ids
1352    }
1353    pub fn candidate_evidence_ids(&self) -> &[EvidenceId] {
1354        &self.candidate_evidence_ids
1355    }
1356}
1357
1358/// Exact policy-authorized plan and its read-only defense-aware subsequence.
1359#[derive(Clone)]
1360pub struct WebAssessmentDefenseShadowPlan {
1361    policy_authorized: AttackPlan,
1362    shadow: AttackPlan,
1363    delta: ShadowPlanDelta,
1364}
1365
1366impl fmt::Debug for WebAssessmentDefenseShadowPlan {
1367    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
1368        formatter
1369            .debug_struct("WebAssessmentDefenseShadowPlan")
1370            .field("policy_authorized", &"<redacted-plan>")
1371            .field("shadow", &"<redacted-plan>")
1372            .field("delta", &"<redacted-delta>")
1373            .finish()
1374    }
1375}
1376
1377impl WebAssessmentDefenseShadowPlan {
1378    pub fn policy_authorized(&self) -> &AttackPlan {
1379        &self.policy_authorized
1380    }
1381    pub fn shadow(&self) -> &AttackPlan {
1382        &self.shadow
1383    }
1384    pub fn delta(&self) -> &ShadowPlanDelta {
1385        &self.delta
1386    }
1387}
1388
1389/// Whether defense is observation-only or monotonically enforced.
1390#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1391pub enum WebAssessmentDefenseMode {
1392    ObservationOnly,
1393    Enforced,
1394}
1395
1396/// Bounded defense audit composed from exact committed subject receipts.
1397#[derive(Clone)]
1398pub struct WebAssessmentDefenseAudit {
1399    mode: WebAssessmentDefenseMode,
1400    observations: Vec<WebAssessmentDefenseObservation>,
1401    transitions: Vec<WebAssessmentDefenseTransition>,
1402    shadow_plans: Vec<WebAssessmentDefenseShadowPlan>,
1403}
1404
1405impl fmt::Debug for WebAssessmentDefenseAudit {
1406    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
1407        formatter
1408            .debug_struct("WebAssessmentDefenseAudit")
1409            .field("mode", &self.mode)
1410            .field("observation_count", &self.observations.len())
1411            .field("transition_count", &self.transitions.len())
1412            .field("shadow_plan_count", &self.shadow_plans.len())
1413            .finish()
1414    }
1415}
1416
1417impl WebAssessmentDefenseAudit {
1418    fn new(mode: WebAssessmentDefenseMode) -> Self {
1419        Self {
1420            mode,
1421            observations: Vec::new(),
1422            transitions: Vec::new(),
1423            shadow_plans: Vec::new(),
1424        }
1425    }
1426
1427    pub const fn mode(&self) -> WebAssessmentDefenseMode {
1428        self.mode
1429    }
1430    pub fn observations(&self) -> &[WebAssessmentDefenseObservation] {
1431        &self.observations
1432    }
1433    pub fn transitions(&self) -> &[WebAssessmentDefenseTransition] {
1434        &self.transitions
1435    }
1436    pub fn shadow_plans(&self) -> &[WebAssessmentDefenseShadowPlan] {
1437        &self.shadow_plans
1438    }
1439
1440    fn append_controller(&mut self, controller: &AssessmentDefenseController) -> Result<(), ()> {
1441        let enforced = self.mode == WebAssessmentDefenseMode::Enforced;
1442        if controller.enforcement_enabled() != enforced {
1443            return Err(());
1444        }
1445        self.observations.extend(
1446            controller
1447                .ledger()
1448                .observations()
1449                .iter()
1450                .map(|observation| {
1451                    let hint = observation
1452                        .state()
1453                        .fingerprint()
1454                        .map(|hint| (hint.product(), hint.confidence()));
1455                    WebAssessmentDefenseObservation {
1456                        subject: observation.case().subject().clone(),
1457                        case_id: observation.case().id().to_owned(),
1458                        stage: observation.stage(),
1459                        status: observation.state().status(),
1460                        posture: if observation.state().posture() == DefensePosture::Open
1461                            && (observation.body_coverage()
1462                                != AssessmentDefenseBodyCoverage::CompleteUtf8Prefix
1463                                || observation.input_limit_reached())
1464                        {
1465                            None
1466                        } else {
1467                            Some(observation.state().posture())
1468                        },
1469                        challenge_observed: observation.state().is_challenged(),
1470                        rate_limit_observed: observation.state().is_rate_limited(),
1471                        fingerprint_hint: hint,
1472                        body_coverage: match observation.body_coverage() {
1473                            AssessmentDefenseBodyCoverage::MetadataOnly => {
1474                                WebAssessmentDefenseBodyCoverage::MetadataOnly
1475                            },
1476                            AssessmentDefenseBodyCoverage::CompleteUtf8Prefix => {
1477                                WebAssessmentDefenseBodyCoverage::CompleteUtf8Prefix
1478                            },
1479                        },
1480                        input_limit_reached: observation.input_limit_reached(),
1481                        evidence_ids: observation.evidence_ids().to_vec(),
1482                    }
1483                }),
1484        );
1485        self.transitions
1486            .extend(controller.ledger().transitions().iter().map(|transition| {
1487                let hint = transition
1488                    .candidate_fingerprint_hint()
1489                    .map(|hint| (hint.product(), hint.confidence()));
1490                WebAssessmentDefenseTransition {
1491                    case_id: transition.case().id().to_owned(),
1492                    candidate_block_status_appeared: transition.candidate_block_status_appeared(),
1493                    newly_rate_limited: transition.newly_rate_limited(),
1494                    candidate_fingerprint_hint: hint,
1495                    control_evidence_ids: transition.control_evidence_ids().to_vec(),
1496                    candidate_evidence_ids: transition.candidate_evidence_ids().to_vec(),
1497                }
1498            }));
1499        self.shadow_plans
1500            .extend(
1501                controller
1502                    .shadows()
1503                    .iter()
1504                    .map(|shadow| WebAssessmentDefenseShadowPlan {
1505                        policy_authorized: shadow.current().clone(),
1506                        shadow: shadow.shadow().clone(),
1507                        delta: shadow.delta().clone(),
1508                    }),
1509            );
1510        Ok(())
1511    }
1512}
1513
1514/// Complete origin-assessment audit with exactly one global transport view.
1515pub struct WebAssessmentRunReport {
1516    #[cfg(feature = "reporting")]
1517    run_started_at: SystemTime,
1518    authorized_root: WebAssessmentSubject,
1519    limits: WebAssessmentLimits,
1520    runtime_active_verification_limit: u16,
1521    runtime_optional_active_verification_allowance: u16,
1522    subjects: Vec<WebAssessmentSubjectReport>,
1523    forms: Vec<WebAssessmentForm>,
1524    semantics: SemanticExtractionResult,
1525    defense: WebAssessmentDefenseAudit,
1526    #[cfg(feature = "supplied-session-review")]
1527    supplied_session: Option<WebAssessmentSuppliedSessionAudit>,
1528    #[cfg(feature = "websocket-review")]
1529    websocket_review: Option<WebAssessmentWebSocketReviewAudit>,
1530    #[cfg(feature = "jwt-target-acceptance-review")]
1531    jwt_target_acceptance: Option<JwtTargetAcceptanceAudit>,
1532    #[cfg(feature = "xml-external-entity-review")]
1533    xml_external_entity_review: Option<XmlExternalEntityReviewAudit>,
1534    #[cfg(feature = "template-evaluation-review")]
1535    template_evaluation_review: Option<TemplateEvaluationReviewAudit>,
1536    #[cfg(feature = "authorization-review")]
1537    authorization_review: Option<WebAssessmentAuthorizationAudit>,
1538    #[cfg(feature = "openapi-review")]
1539    openapi_review: Option<WebAssessmentOpenApiAudit>,
1540    #[cfg(feature = "rest-review")]
1541    rest_review: Option<WebAssessmentRestAudit>,
1542    #[cfg(feature = "ssrf-oast-review")]
1543    ssrf_oast_review: Option<WebAssessmentSsrfOastAudit>,
1544    #[cfg(feature = "wordpress-review")]
1545    wordpress_review: Option<WebAssessmentWordPressAudit>,
1546    #[cfg(feature = "secret-exposure-review")]
1547    secret_exposure_review: Option<WebAssessmentSecretExposureAudit>,
1548    #[cfg(feature = "tls-observation")]
1549    tls_observation: Option<WebAssessmentTlsObservationAudit>,
1550    #[cfg(feature = "tls-negotiation-review")]
1551    tls_negotiation_review: Option<WebAssessmentTlsNegotiationReviewAudit>,
1552    #[cfg(feature = "recon-ct-provider")]
1553    recon_ct_provider: Option<WebAssessmentReconCtProviderAudit>,
1554    assessment_items: AssessmentItemSet,
1555    assessment_projection_incompleteness: PassiveAssessmentProjectionIncompleteness,
1556    completion: WebAssessmentCompletion,
1557    usage: WebAssessmentUsage,
1558    transport: TransportDispatchAudit,
1559}
1560
1561impl fmt::Debug for WebAssessmentRunReport {
1562    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
1563        let mut debug = formatter.debug_struct("WebAssessmentRunReport");
1564        #[cfg(feature = "reporting")]
1565        debug.field("run_started_at", &"<runtime-owned>");
1566        debug
1567            .field("authorized_root", &"<redacted>")
1568            .field("limits", &self.limits)
1569            .field(
1570                "runtime_active_verification_limit",
1571                &self.runtime_active_verification_limit,
1572            )
1573            .field("subject_count", &self.subjects.len())
1574            .field("form_count", &self.forms.len())
1575            .field("semantics", &"<redacted>")
1576            .field("defense", &self.defense);
1577        #[cfg(feature = "authorization-review")]
1578        debug.field("authorization_review", &self.authorization_review);
1579        #[cfg(feature = "supplied-session-review")]
1580        debug.field("supplied_session", &self.supplied_session);
1581        #[cfg(feature = "websocket-review")]
1582        debug.field(
1583            "websocket_review",
1584            &self.websocket_review.as_ref().map(|_| "<redacted-audit>"),
1585        );
1586        #[cfg(feature = "jwt-target-acceptance-review")]
1587        debug.field("jwt_target_acceptance", &self.jwt_target_acceptance);
1588        #[cfg(feature = "xml-external-entity-review")]
1589        debug.field(
1590            "xml_external_entity_review",
1591            &self.xml_external_entity_review,
1592        );
1593        #[cfg(feature = "template-evaluation-review")]
1594        debug.field(
1595            "template_evaluation_review",
1596            &self.template_evaluation_review,
1597        );
1598        #[cfg(feature = "openapi-review")]
1599        debug.field("openapi_review", &self.openapi_review);
1600        #[cfg(feature = "rest-review")]
1601        debug.field("rest_review", &self.rest_review);
1602        #[cfg(feature = "ssrf-oast-review")]
1603        debug.field("ssrf_oast_review", &self.ssrf_oast_review);
1604        #[cfg(feature = "wordpress-review")]
1605        debug.field("wordpress_review", &self.wordpress_review);
1606        #[cfg(feature = "secret-exposure-review")]
1607        debug.field("secret_exposure_review", &self.secret_exposure_review);
1608        #[cfg(feature = "tls-observation")]
1609        debug.field("tls_observation", &self.tls_observation);
1610        #[cfg(feature = "tls-negotiation-review")]
1611        debug.field("tls_negotiation_review", &self.tls_negotiation_review);
1612        #[cfg(feature = "recon-ct-provider")]
1613        debug.field(
1614            "recon_ct_provider",
1615            &self.recon_ct_provider.as_ref().map(|_| "<redacted-audit>"),
1616        );
1617        debug
1618            .field("assessment_items", &self.assessment_items)
1619            .field(
1620                "unprojected_subject_count",
1621                &self
1622                    .assessment_projection_incompleteness
1623                    .non_root_observations(),
1624            )
1625            .field(
1626                "unprojected_condition_count",
1627                &self
1628                    .assessment_projection_incompleteness
1629                    .non_root_conditions(),
1630            )
1631            .field("completion", &self.completion)
1632            .field("usage", &self.usage)
1633            .field("transport", &"<redacted-audit>")
1634            .finish()
1635    }
1636}
1637
1638impl WebAssessmentRunReport {
1639    /// Returns the exact query-free resource that started this assessment.
1640    ///
1641    /// Product adapters must bind any separately constructed run envelope to
1642    /// this identity instead of relying on same-origin equivalence.
1643    pub fn authorized_root(&self) -> &WebAssessmentSubject {
1644        &self.authorized_root
1645    }
1646    /// Returns the profile-owned base limits that governed this assessment.
1647    /// Feature-local additive authority, when present, is exposed separately.
1648    pub const fn limits(&self) -> WebAssessmentLimits {
1649        self.limits
1650    }
1651    /// Returns the actual shared active-verification ceiling used at startup.
1652    pub const fn runtime_active_verification_limit(&self) -> u16 {
1653        self.runtime_active_verification_limit
1654    }
1655    pub fn subjects(&self) -> &[WebAssessmentSubjectReport] {
1656        &self.subjects
1657    }
1658    pub fn forms(&self) -> &[WebAssessmentForm] {
1659        &self.forms
1660    }
1661    /// Returns bounded semantic entities derived only from exact committed
1662    /// assessment evidence.
1663    pub fn semantics(&self) -> &SemanticExtractionResult {
1664        &self.semantics
1665    }
1666    pub fn defense(&self) -> &WebAssessmentDefenseAudit {
1667        &self.defense
1668    }
1669    /// Returns the optional value-free supplied-session execution audit.
1670    #[cfg(feature = "supplied-session-review")]
1671    pub const fn supplied_session_audit(&self) -> Option<&WebAssessmentSuppliedSessionAudit> {
1672        self.supplied_session.as_ref()
1673    }
1674    /// Returns the optional redaction-safe four-view authorization audit.
1675    #[cfg(feature = "authorization-review")]
1676    pub const fn authorization_review_audit(&self) -> Option<&WebAssessmentAuthorizationAudit> {
1677        self.authorization_review.as_ref()
1678    }
1679    /// Returns the optional bounded, anonymous WebSocket protocol audit.
1680    #[cfg(feature = "websocket-review")]
1681    pub const fn websocket_review_audit(&self) -> Option<&WebAssessmentWebSocketReviewAudit> {
1682        self.websocket_review.as_ref()
1683    }
1684    /// Returns the optional value-free six-leg JWT target-acceptance audit.
1685    #[cfg(feature = "jwt-target-acceptance-review")]
1686    pub const fn jwt_target_acceptance_audit(&self) -> Option<&JwtTargetAcceptanceAudit> {
1687        self.jwt_target_acceptance.as_ref()
1688    }
1689    /// Returns the value-free controlled XML prefix preserved before failure.
1690    #[cfg(feature = "xml-external-entity-review")]
1691    pub const fn xml_external_entity_review_audit(&self) -> Option<&XmlExternalEntityReviewAudit> {
1692        self.xml_external_entity_review.as_ref()
1693    }
1694    /// Returns the optional raw-free harmless expression-semantics audit.
1695    #[cfg(feature = "template-evaluation-review")]
1696    pub const fn template_evaluation_review_audit(&self) -> Option<&TemplateEvaluationReviewAudit> {
1697        self.template_evaluation_review.as_ref()
1698    }
1699    #[cfg(feature = "openapi-review")]
1700    pub const fn openapi_review_audit(&self) -> Option<&WebAssessmentOpenApiAudit> {
1701        self.openapi_review.as_ref()
1702    }
1703    #[cfg(feature = "rest-review")]
1704    pub const fn rest_review_audit(&self) -> Option<&WebAssessmentRestAudit> {
1705        self.rest_review.as_ref()
1706    }
1707    #[cfg(feature = "ssrf-oast-review")]
1708    pub const fn ssrf_oast_review_audit(&self) -> Option<&WebAssessmentSsrfOastAudit> {
1709        self.ssrf_oast_review.as_ref()
1710    }
1711    /// Returns the optional transport-free WordPress interpretation audit.
1712    #[cfg(feature = "wordpress-review")]
1713    pub const fn wordpress_review_audit(&self) -> Option<&WebAssessmentWordPressAudit> {
1714        self.wordpress_review.as_ref()
1715    }
1716    /// Returns the optional value-free audit of already acquired anonymous
1717    /// ordinary response bodies.
1718    #[cfg(feature = "secret-exposure-review")]
1719    pub const fn secret_exposure_review_audit(&self) -> Option<&WebAssessmentSecretExposureAudit> {
1720        self.secret_exposure_review.as_ref()
1721    }
1722    /// Returns the optional passive observation of TLS already used by this run.
1723    #[cfg(feature = "tls-observation")]
1724    pub const fn tls_observation_audit(&self) -> Option<&WebAssessmentTlsObservationAudit> {
1725        self.tls_observation.as_ref()
1726    }
1727    /// Returns the explicitly selected active TLS negotiation matrix.
1728    #[cfg(feature = "tls-negotiation-review")]
1729    pub const fn tls_negotiation_review_audit(
1730        &self,
1731    ) -> Option<&WebAssessmentTlsNegotiationReviewAudit> {
1732        self.tls_negotiation_review.as_ref()
1733    }
1734    /// Returns the optional bounded Cert Spotter provider collection audit.
1735    #[cfg(feature = "recon-ct-provider")]
1736    pub const fn recon_ct_provider_audit(&self) -> Option<&WebAssessmentReconCtProviderAudit> {
1737        self.recon_ct_provider.as_ref()
1738    }
1739    /// Returns claim-safe items derived only from committed assessment truth.
1740    pub fn assessment_items(&self) -> &[AssessmentItem] {
1741        self.assessment_items.items()
1742    }
1743    /// Returns the number of discovered subjects whose reportable passive
1744    /// conditions lacked a host-approved stable product identity.
1745    pub const fn unprojected_assessment_subjects(&self) -> u16 {
1746        self.assessment_projection_incompleteness
1747            .non_root_observations()
1748    }
1749    /// Returns reportable passive conditions omitted at that identity boundary.
1750    pub const fn unprojected_assessment_conditions(&self) -> u16 {
1751        self.assessment_projection_incompleteness
1752            .non_root_conditions()
1753    }
1754    pub fn completion(&self) -> &WebAssessmentCompletion {
1755        &self.completion
1756    }
1757    pub const fn usage(&self) -> WebAssessmentUsage {
1758        self.usage
1759    }
1760    pub fn transport(&self) -> &TransportDispatchAudit {
1761        &self.transport
1762    }
1763
1764    /// Consumes the origin audit into the crate-owned typed product envelope.
1765    ///
1766    /// This remains crate-private so only the reporting layer can mint the run
1767    /// envelope from this report's runtime-owned timing and accounting. A
1768    /// public caller cannot pair this audit with an independently constructed
1769    /// generic [`termivar_core::RunReport`]. The existing `decision-scan/v1`
1770    /// report remains a separate contract.
1771    #[cfg(feature = "reporting")]
1772    pub(crate) fn into_assessment_report(
1773        self,
1774        profile: ScanProfileV1,
1775    ) -> Result<AssessmentRunReport, AssessmentRunReportError> {
1776        let truth = CompletedWebAssessmentTruth::new(
1777            self.run_started_at,
1778            &self.authorized_root,
1779            AssessmentRuntimeLimits::new(
1780                self.limits,
1781                self.runtime_active_verification_limit,
1782                self.runtime_optional_active_verification_allowance,
1783            ),
1784            self.usage,
1785            &self.completion,
1786            self.defense.mode(),
1787            profile,
1788        )?;
1789        AssessmentRunReport::from_completed_truth(
1790            self.assessment_items,
1791            truth,
1792            #[cfg(feature = "supplied-session-review")]
1793            self.supplied_session,
1794            #[cfg(feature = "authorization-review")]
1795            self.authorization_review,
1796            #[cfg(feature = "websocket-review")]
1797            self.websocket_review,
1798            #[cfg(feature = "jwt-target-acceptance-review")]
1799            self.jwt_target_acceptance,
1800            #[cfg(feature = "openapi-review")]
1801            self.openapi_review,
1802            #[cfg(feature = "rest-review")]
1803            self.rest_review,
1804            #[cfg(feature = "ssrf-oast-review")]
1805            self.ssrf_oast_review,
1806            #[cfg(feature = "xml-external-entity-review")]
1807            self.xml_external_entity_review,
1808            #[cfg(feature = "template-evaluation-review")]
1809            self.template_evaluation_review,
1810            #[cfg(feature = "wordpress-review")]
1811            self.wordpress_review,
1812            #[cfg(feature = "secret-exposure-review")]
1813            self.secret_exposure_review,
1814            #[cfg(feature = "tls-observation")]
1815            self.tls_observation,
1816            #[cfg(feature = "tls-negotiation-review")]
1817            self.tls_negotiation_review,
1818            #[cfg(feature = "recon-ct-provider")]
1819            self.recon_ct_provider,
1820            &self.transport,
1821        )
1822    }
1823}
1824
1825/// Durable outer audit retained when a started assessment fails.
1826pub struct WebAssessmentFailureReceipt {
1827    completed_subjects: Vec<WebAssessmentSubjectReport>,
1828    pending_subjects: Vec<WebAssessmentSubject>,
1829    forms: Vec<WebAssessmentForm>,
1830    semantics: SemanticExtractionResult,
1831    defense: WebAssessmentDefenseAudit,
1832    #[cfg(feature = "supplied-session-review")]
1833    supplied_session: Option<WebAssessmentSuppliedSessionAudit>,
1834    #[cfg(feature = "websocket-review")]
1835    websocket_review: Option<WebAssessmentWebSocketReviewAudit>,
1836    #[cfg(feature = "jwt-target-acceptance-review")]
1837    jwt_target_acceptance: Option<JwtTargetAcceptanceAudit>,
1838    #[cfg(feature = "xml-external-entity-review")]
1839    xml_external_entity_review: Option<XmlExternalEntityReviewAudit>,
1840    current_subject: WebAssessmentSubjectReport,
1841    incomplete_reasons: BTreeSet<WebAssessmentIncompleteReason>,
1842    inventory_consistent: bool,
1843    unrepresented_ledger_subjects: usize,
1844    committed_passive_observations: usize,
1845    usage: WebAssessmentUsage,
1846    transport: TransportDispatchAudit,
1847}
1848
1849impl fmt::Debug for WebAssessmentFailureReceipt {
1850    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
1851        let mut debug = formatter.debug_struct("WebAssessmentFailureReceipt");
1852        debug
1853            .field("completed_subject_count", &self.completed_subjects.len())
1854            .field("pending_subject_count", &self.pending_subjects.len())
1855            .field("form_count", &self.forms.len())
1856            .field("semantics", &"<redacted>")
1857            .field("defense", &self.defense);
1858        #[cfg(feature = "supplied-session-review")]
1859        debug.field("supplied_session", &self.supplied_session);
1860        #[cfg(feature = "websocket-review")]
1861        debug.field(
1862            "websocket_review",
1863            &self.websocket_review.as_ref().map(|_| "<redacted-audit>"),
1864        );
1865        #[cfg(feature = "jwt-target-acceptance-review")]
1866        debug.field("jwt_target_acceptance", &self.jwt_target_acceptance);
1867        #[cfg(feature = "xml-external-entity-review")]
1868        debug.field(
1869            "xml_external_entity_review",
1870            &self.xml_external_entity_review,
1871        );
1872        debug
1873            .field("current_subject", &self.current_subject)
1874            .field("incomplete_reasons", &self.incomplete_reasons)
1875            .field("inventory_consistent", &self.inventory_consistent)
1876            .field(
1877                "unrepresented_ledger_subjects",
1878                &self.unrepresented_ledger_subjects,
1879            )
1880            .field(
1881                "committed_passive_observations",
1882                &self.committed_passive_observations,
1883            )
1884            .field("usage", &self.usage)
1885            .field("transport", &"<redacted-audit>")
1886            .finish()
1887    }
1888}
1889
1890impl WebAssessmentFailureReceipt {
1891    pub fn completed_subjects(&self) -> &[WebAssessmentSubjectReport] {
1892        &self.completed_subjects
1893    }
1894    /// Returns committed discovered subjects that had not reached execution.
1895    pub fn pending_subjects(&self) -> &[WebAssessmentSubject] {
1896        &self.pending_subjects
1897    }
1898    pub fn forms(&self) -> &[WebAssessmentForm] {
1899        &self.forms
1900    }
1901    /// Returns semantic truth preserved from committed assessment evidence
1902    /// before the failing boundary.
1903    pub fn semantics(&self) -> &SemanticExtractionResult {
1904        &self.semantics
1905    }
1906    pub fn defense(&self) -> &WebAssessmentDefenseAudit {
1907        &self.defense
1908    }
1909    /// Returns the supplied-session prefix preserved before a later run failure.
1910    #[cfg(feature = "supplied-session-review")]
1911    pub const fn supplied_session_audit(&self) -> Option<&WebAssessmentSuppliedSessionAudit> {
1912        self.supplied_session.as_ref()
1913    }
1914    /// Returns the value-free WebSocket prefix preserved before a later run failure.
1915    #[cfg(feature = "websocket-review")]
1916    pub const fn websocket_review_audit(&self) -> Option<&WebAssessmentWebSocketReviewAudit> {
1917        self.websocket_review.as_ref()
1918    }
1919    /// Returns the value-free JWT target prefix preserved before a later run failure.
1920    #[cfg(feature = "jwt-target-acceptance-review")]
1921    pub const fn jwt_target_acceptance_audit(&self) -> Option<&JwtTargetAcceptanceAudit> {
1922        self.jwt_target_acceptance.as_ref()
1923    }
1924    /// Returns the value-free controlled XML prefix preserved before a later failure.
1925    #[cfg(feature = "xml-external-entity-review")]
1926    pub const fn xml_external_entity_review_audit(&self) -> Option<&XmlExternalEntityReviewAudit> {
1927        self.xml_external_entity_review.as_ref()
1928    }
1929    pub fn current_subject(&self) -> &WebAssessmentSubject {
1930        &self.current_subject.subject
1931    }
1932    /// Returns subject-local work at the failing boundary without a duplicate
1933    /// global transport or usage snapshot.
1934    pub fn current_subject_report(&self) -> &WebAssessmentSubjectReport {
1935        &self.current_subject
1936    }
1937    /// Returns every bounded or execution condition known at failure time.
1938    pub fn incomplete_reasons(&self) -> &BTreeSet<WebAssessmentIncompleteReason> {
1939        &self.incomplete_reasons
1940    }
1941    /// Returns whether typed subject/form inventory exactly matched the ledger.
1942    pub const fn inventory_consistent(&self) -> bool {
1943        self.inventory_consistent
1944    }
1945    /// Returns ledger subject identities absent from the typed known inventory.
1946    pub const fn unrepresented_ledger_subjects(&self) -> usize {
1947        self.unrepresented_ledger_subjects
1948    }
1949    /// Returns the bounded passive observations retained before failure.
1950    ///
1951    /// Started failures intentionally do not project user-facing assessment
1952    /// items: execution is incomplete, so callers receive this count and the
1953    /// typed failure inventory instead of a partial finding collection.
1954    pub const fn committed_passive_observations(&self) -> usize {
1955        self.committed_passive_observations
1956    }
1957    pub const fn usage(&self) -> WebAssessmentUsage {
1958        self.usage
1959    }
1960    pub fn transport(&self) -> &TransportDispatchAudit {
1961        &self.transport
1962    }
1963}
1964
1965/// Construction and execution failures for [`WebAssessmentRuntime`].
1966#[derive(Error)]
1967#[non_exhaustive]
1968pub enum WebAssessmentRuntimeError {
1969    #[error("web assessment runtime has already started")]
1970    AlreadyStarted,
1971    #[error(transparent)]
1972    Limits(#[from] WebAssessmentLimitsError),
1973    #[error(transparent)]
1974    SemanticLimits(#[from] LimitsError),
1975    #[error(transparent)]
1976    Http(#[from] HttpEvidenceError),
1977    #[error("authorized web assessment target cannot be retained safely")]
1978    InvalidCanonicalTarget,
1979    #[error("authorized root exceeds the total retained URL byte limit")]
1980    RootRetentionLimit,
1981    #[error("native low-risk web review could not be composed safely")]
1982    NativeReviewComposition,
1983    #[cfg(feature = "recon-ct-provider")]
1984    #[error("certificate-transparency provider policy is not bound to the selected assessment")]
1985    ReconCtProviderComposition,
1986    #[error("root authorization-context review requires an exact origin root target")]
1987    RootAuthorizationContextRequiresOriginRoot,
1988    #[error("root API authorization-context review could not be composed safely")]
1989    ApiVisibilityComposition,
1990    #[cfg(feature = "authorization-review")]
1991    #[error("resource authorization review could not be composed safely")]
1992    AuthorizationReviewComposition,
1993    #[cfg(feature = "authorization-review")]
1994    #[error("resource authorization review conflicts with root authorization context")]
1995    AuthorizationReviewConflict,
1996    #[cfg(feature = "authorization-review")]
1997    #[error("resource authorization review requires protected authenticated transport")]
1998    InsecureAuthorizationReviewTransport,
1999    #[cfg(feature = "websocket-review")]
2000    #[error("WebSocket review policy does not match the exact assessment application")]
2001    WebSocketReviewApplicationMismatch,
2002    #[cfg(all(feature = "websocket-review", feature = "supplied-session-review"))]
2003    #[error("WebSocket supplied-session integration requires both selected reviews")]
2004    WebSocketSuppliedSessionRequiresInputs,
2005    #[cfg(all(feature = "websocket-review", feature = "supplied-session-review"))]
2006    #[error("WebSocket supplied-session integration supports one V1 Authorization resource")]
2007    WebSocketSuppliedSessionUnsupportedPolicy,
2008    #[cfg(all(
2009        feature = "websocket-review",
2010        feature = "supplied-session-review",
2011        feature = "wordpress-review"
2012    ))]
2013    #[error("WebSocket and WordPress supplied-session integrations cannot share one session")]
2014    WebSocketSuppliedSessionWordPressConflict,
2015    #[cfg(feature = "jwt-target-acceptance-review")]
2016    #[error("JWT target-acceptance policy does not match the exact assessment application")]
2017    JwtTargetAcceptanceApplicationMismatch,
2018    #[cfg(feature = "jwt-target-acceptance-review")]
2019    #[error("JWT target-acceptance review requires protected authenticated transport")]
2020    InsecureJwtTargetAcceptanceTransport,
2021    #[cfg(feature = "jwt-target-acceptance-review")]
2022    #[error("JWT target-acceptance review could not be composed safely")]
2023    JwtTargetAcceptanceComposition,
2024    #[cfg(feature = "jwt-target-acceptance-review")]
2025    #[error("JWT target-acceptance review failed after assessment start")]
2026    JwtTargetAcceptanceExecution {
2027        receipt: Box<WebAssessmentFailureReceipt>,
2028    },
2029    #[cfg(feature = "xml-external-entity-review")]
2030    #[error("controlled XML review policy does not match the selected assessment application")]
2031    XmlExternalEntityApplicationMismatch,
2032    #[cfg(all(feature = "xml-external-entity-review", feature = "ssrf-oast-review"))]
2033    #[error(
2034        "controlled XML review and SSRF/OAST review cannot share one native provider lifecycle"
2035    )]
2036    XmlExternalEntitySsrfOastConflict,
2037    #[cfg(feature = "xml-external-entity-review")]
2038    #[error("controlled XML review failed after assessment start")]
2039    XmlExternalEntityExecution {
2040        receipt: Box<WebAssessmentFailureReceipt>,
2041    },
2042    #[cfg(feature = "supplied-session-review")]
2043    #[error("supplied-session policy does not match the exact assessment application")]
2044    SuppliedSessionApplicationMismatch,
2045    #[cfg(feature = "supplied-session-review")]
2046    #[error("supplied-session review requires protected authenticated transport")]
2047    InsecureSuppliedSessionTransport,
2048    #[cfg(feature = "supplied-session-review")]
2049    #[error("supplied-session review could not be composed safely")]
2050    SuppliedSessionComposition,
2051    #[cfg(feature = "secret-exposure-review")]
2052    #[error(
2053        "passive secret-exposure review conflicts with a response source that lacks its value-free body-digest boundary"
2054    )]
2055    SecretExposureResponseSourceConflict,
2056    #[cfg(feature = "tls-negotiation-review")]
2057    #[error("active TLS negotiation requires one credential-free DNS HTTPS target")]
2058    TlsNegotiationReviewComposition,
2059    #[cfg(feature = "rest-review")]
2060    #[error("REST review requires OpenAPI review in the same assessment")]
2061    RestReviewRequiresOpenApiReview,
2062    #[cfg(feature = "wordpress-review")]
2063    #[error(
2064        "WordPress evidence review requires an exact root or trailing-slash application target"
2065    )]
2066    WordPressReviewRequiresOriginRoot,
2067    #[cfg(feature = "wordpress-review")]
2068    #[error("a non-root WordPress application target requires explicit metadata discovery")]
2069    WordPressNonRootRequiresDiscovery,
2070    #[cfg(feature = "wordpress-review")]
2071    #[error("WordPress metadata discovery requires WordPress review in the same assessment")]
2072    WordPressDiscoveryRequiresReview,
2073    #[cfg(feature = "wordpress-review")]
2074    #[error("WordPress page scope requires metadata discovery in the same assessment")]
2075    WordPressPageScopeRequiresDiscovery,
2076    #[cfg(feature = "wordpress-review")]
2077    #[error("WordPress layout declarations require metadata discovery in the same assessment")]
2078    WordPressLayoutRequiresDiscovery,
2079    #[cfg(feature = "wordpress-review")]
2080    #[error("WordPress asset fingerprints require metadata discovery in the same assessment")]
2081    WordPressAssetFingerprintsRequireDiscovery,
2082    #[cfg(all(feature = "wordpress-review", feature = "supplied-session-review"))]
2083    #[error(
2084        "WordPress supplied-session integration requires WordPress review, metadata discovery, and a supplied session"
2085    )]
2086    WordPressSuppliedSessionRequiresInputs,
2087    #[cfg(all(feature = "wordpress-review", feature = "supplied-session-review"))]
2088    #[error("WordPress supplied-session integration does not support renewal or OAuth policies")]
2089    WordPressSuppliedSessionUnsupportedPolicy,
2090    #[cfg(feature = "wordpress-review")]
2091    #[error("WordPress context root does not match the exact assessment target")]
2092    WordPressContextRootMismatch,
2093    #[error(transparent)]
2094    Standard(#[from] StandardWebDecisionRuntimeError),
2095    #[error("web assessment failed after it started")]
2096    RunFailed {
2097        receipt: Box<WebAssessmentFailureReceipt>,
2098        #[source]
2099        source: Box<StandardWebDecisionRuntimeError>,
2100    },
2101    #[error("root API authorization-context review failed after it started")]
2102    ApiVisibilityRunFailed {
2103        receipt: Box<WebAssessmentFailureReceipt>,
2104        #[source]
2105        source: Box<RuntimeApiVisibilityExecutionError>,
2106    },
2107    #[error("committed web discovery evidence violated its typed projection contract")]
2108    ProjectionInvariant {
2109        receipt: Box<WebAssessmentFailureReceipt>,
2110    },
2111}
2112
2113impl fmt::Debug for WebAssessmentRuntimeError {
2114    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
2115        match self {
2116            Self::AlreadyStarted => {
2117                formatter.write_str("WebAssessmentRuntimeError::AlreadyStarted")
2118            },
2119            Self::Limits(_) => formatter.write_str("WebAssessmentRuntimeError::Limits(<redacted>)"),
2120            Self::SemanticLimits(_) => {
2121                formatter.write_str("WebAssessmentRuntimeError::SemanticLimits(<redacted>)")
2122            },
2123            Self::Http(_) => formatter.write_str("WebAssessmentRuntimeError::Http(<redacted>)"),
2124            Self::InvalidCanonicalTarget => {
2125                formatter.write_str("WebAssessmentRuntimeError::InvalidCanonicalTarget")
2126            },
2127            Self::RootRetentionLimit => {
2128                formatter.write_str("WebAssessmentRuntimeError::RootRetentionLimit")
2129            },
2130            Self::NativeReviewComposition => {
2131                formatter.write_str("WebAssessmentRuntimeError::NativeReviewComposition")
2132            },
2133            #[cfg(feature = "recon-ct-provider")]
2134            Self::ReconCtProviderComposition => {
2135                formatter.write_str("WebAssessmentRuntimeError::ReconCtProviderComposition")
2136            },
2137            Self::RootAuthorizationContextRequiresOriginRoot => formatter
2138                .write_str("WebAssessmentRuntimeError::RootAuthorizationContextRequiresOriginRoot"),
2139            Self::ApiVisibilityComposition => {
2140                formatter.write_str("WebAssessmentRuntimeError::ApiVisibilityComposition")
2141            },
2142            #[cfg(feature = "authorization-review")]
2143            Self::AuthorizationReviewComposition => {
2144                formatter.write_str("WebAssessmentRuntimeError::AuthorizationReviewComposition")
2145            },
2146            #[cfg(feature = "authorization-review")]
2147            Self::AuthorizationReviewConflict => {
2148                formatter.write_str("WebAssessmentRuntimeError::AuthorizationReviewConflict")
2149            },
2150            #[cfg(feature = "authorization-review")]
2151            Self::InsecureAuthorizationReviewTransport => formatter
2152                .write_str("WebAssessmentRuntimeError::InsecureAuthorizationReviewTransport"),
2153            #[cfg(feature = "websocket-review")]
2154            Self::WebSocketReviewApplicationMismatch => {
2155                formatter.write_str("WebAssessmentRuntimeError::WebSocketReviewApplicationMismatch")
2156            },
2157            #[cfg(all(feature = "websocket-review", feature = "supplied-session-review"))]
2158            Self::WebSocketSuppliedSessionRequiresInputs => formatter
2159                .write_str("WebAssessmentRuntimeError::WebSocketSuppliedSessionRequiresInputs"),
2160            #[cfg(all(feature = "websocket-review", feature = "supplied-session-review"))]
2161            Self::WebSocketSuppliedSessionUnsupportedPolicy => formatter
2162                .write_str("WebAssessmentRuntimeError::WebSocketSuppliedSessionUnsupportedPolicy"),
2163            #[cfg(all(
2164                feature = "websocket-review",
2165                feature = "supplied-session-review",
2166                feature = "wordpress-review"
2167            ))]
2168            Self::WebSocketSuppliedSessionWordPressConflict => formatter
2169                .write_str("WebAssessmentRuntimeError::WebSocketSuppliedSessionWordPressConflict"),
2170            #[cfg(feature = "jwt-target-acceptance-review")]
2171            Self::JwtTargetAcceptanceApplicationMismatch => formatter
2172                .write_str("WebAssessmentRuntimeError::JwtTargetAcceptanceApplicationMismatch"),
2173            #[cfg(feature = "jwt-target-acceptance-review")]
2174            Self::InsecureJwtTargetAcceptanceTransport => formatter
2175                .write_str("WebAssessmentRuntimeError::InsecureJwtTargetAcceptanceTransport"),
2176            #[cfg(feature = "jwt-target-acceptance-review")]
2177            Self::JwtTargetAcceptanceComposition => {
2178                formatter.write_str("WebAssessmentRuntimeError::JwtTargetAcceptanceComposition")
2179            },
2180            #[cfg(feature = "jwt-target-acceptance-review")]
2181            Self::JwtTargetAcceptanceExecution { receipt } => formatter
2182                .debug_struct("WebAssessmentRuntimeError::JwtTargetAcceptanceExecution")
2183                .field("receipt", receipt)
2184                .finish(),
2185            #[cfg(feature = "xml-external-entity-review")]
2186            Self::XmlExternalEntityApplicationMismatch => formatter
2187                .write_str("WebAssessmentRuntimeError::XmlExternalEntityApplicationMismatch"),
2188            #[cfg(all(feature = "xml-external-entity-review", feature = "ssrf-oast-review"))]
2189            Self::XmlExternalEntitySsrfOastConflict => {
2190                formatter.write_str("WebAssessmentRuntimeError::XmlExternalEntitySsrfOastConflict")
2191            },
2192            #[cfg(feature = "xml-external-entity-review")]
2193            Self::XmlExternalEntityExecution { receipt } => formatter
2194                .debug_struct("WebAssessmentRuntimeError::XmlExternalEntityExecution")
2195                .field("receipt", receipt)
2196                .finish(),
2197            #[cfg(feature = "supplied-session-review")]
2198            Self::SuppliedSessionApplicationMismatch => {
2199                formatter.write_str("WebAssessmentRuntimeError::SuppliedSessionApplicationMismatch")
2200            },
2201            #[cfg(feature = "supplied-session-review")]
2202            Self::InsecureSuppliedSessionTransport => {
2203                formatter.write_str("WebAssessmentRuntimeError::InsecureSuppliedSessionTransport")
2204            },
2205            #[cfg(feature = "supplied-session-review")]
2206            Self::SuppliedSessionComposition => {
2207                formatter.write_str("WebAssessmentRuntimeError::SuppliedSessionComposition")
2208            },
2209            #[cfg(feature = "secret-exposure-review")]
2210            Self::SecretExposureResponseSourceConflict => formatter
2211                .write_str("WebAssessmentRuntimeError::SecretExposureResponseSourceConflict"),
2212            #[cfg(feature = "tls-negotiation-review")]
2213            Self::TlsNegotiationReviewComposition => {
2214                formatter.write_str("WebAssessmentRuntimeError::TlsNegotiationReviewComposition")
2215            },
2216            #[cfg(feature = "rest-review")]
2217            Self::RestReviewRequiresOpenApiReview => {
2218                formatter.write_str("WebAssessmentRuntimeError::RestReviewRequiresOpenApiReview")
2219            },
2220            #[cfg(feature = "wordpress-review")]
2221            Self::WordPressReviewRequiresOriginRoot => {
2222                formatter.write_str("WebAssessmentRuntimeError::WordPressReviewRequiresOriginRoot")
2223            },
2224            #[cfg(feature = "wordpress-review")]
2225            Self::WordPressNonRootRequiresDiscovery => {
2226                formatter.write_str("WebAssessmentRuntimeError::WordPressNonRootRequiresDiscovery")
2227            },
2228            #[cfg(feature = "wordpress-review")]
2229            Self::WordPressDiscoveryRequiresReview => {
2230                formatter.write_str("WebAssessmentRuntimeError::WordPressDiscoveryRequiresReview")
2231            },
2232            #[cfg(feature = "wordpress-review")]
2233            Self::WordPressPageScopeRequiresDiscovery => formatter
2234                .write_str("WebAssessmentRuntimeError::WordPressPageScopeRequiresDiscovery"),
2235            #[cfg(feature = "wordpress-review")]
2236            Self::WordPressLayoutRequiresDiscovery => {
2237                formatter.write_str("WebAssessmentRuntimeError::WordPressLayoutRequiresDiscovery")
2238            },
2239            #[cfg(feature = "wordpress-review")]
2240            Self::WordPressAssetFingerprintsRequireDiscovery => formatter
2241                .write_str("WebAssessmentRuntimeError::WordPressAssetFingerprintsRequireDiscovery"),
2242            #[cfg(all(feature = "wordpress-review", feature = "supplied-session-review"))]
2243            Self::WordPressSuppliedSessionRequiresInputs => formatter
2244                .write_str("WebAssessmentRuntimeError::WordPressSuppliedSessionRequiresInputs"),
2245            #[cfg(all(feature = "wordpress-review", feature = "supplied-session-review"))]
2246            Self::WordPressSuppliedSessionUnsupportedPolicy => formatter
2247                .write_str("WebAssessmentRuntimeError::WordPressSuppliedSessionUnsupportedPolicy"),
2248            #[cfg(feature = "wordpress-review")]
2249            Self::WordPressContextRootMismatch => {
2250                formatter.write_str("WebAssessmentRuntimeError::WordPressContextRootMismatch")
2251            },
2252            Self::Standard(_) => {
2253                formatter.write_str("WebAssessmentRuntimeError::Standard(<redacted>)")
2254            },
2255            Self::RunFailed { receipt, .. } => formatter
2256                .debug_struct("WebAssessmentRuntimeError::RunFailed")
2257                .field("receipt", receipt)
2258                .field("source", &"<redacted>")
2259                .finish(),
2260            Self::ApiVisibilityRunFailed { receipt, .. } => formatter
2261                .debug_struct("WebAssessmentRuntimeError::ApiVisibilityRunFailed")
2262                .field("receipt", receipt)
2263                .field("source", &"<redacted>")
2264                .finish(),
2265            Self::ProjectionInvariant { receipt } => formatter
2266                .debug_struct("WebAssessmentRuntimeError::ProjectionInvariant")
2267                .field("receipt", receipt)
2268                .finish(),
2269        }
2270    }
2271}
2272
2273#[cfg(feature = "wordpress-review")]
2274fn wordpress_application_target_is_valid(target: &Url) -> bool {
2275    if !matches!(target.scheme(), "http" | "https")
2276        || !target.has_host()
2277        || !target.username().is_empty()
2278        || target.password().is_some()
2279        || target.query().is_some()
2280        || target.fragment().is_some()
2281        || !target.path().starts_with('/')
2282        || !target.path().ends_with('/')
2283        || target.path().contains('\\')
2284    {
2285        return false;
2286    }
2287    let lowercase = target.path().to_ascii_lowercase();
2288    !["%2e", "%2f", "%5c", "%25"]
2289        .iter()
2290        .any(|encoded| lowercase.contains(encoded))
2291        && !target
2292            .path()
2293            .split('/')
2294            .enumerate()
2295            .any(|(index, segment)| {
2296                matches!(segment, "." | "..")
2297                    || (segment.is_empty()
2298                        && index != 0
2299                        && index + 1 != target.path().split('/').count())
2300            })
2301}
2302
2303impl WebAssessmentRuntimeError {
2304    pub fn failure_receipt(&self) -> Option<&WebAssessmentFailureReceipt> {
2305        match self {
2306            Self::RunFailed { receipt, .. }
2307            | Self::ApiVisibilityRunFailed { receipt, .. }
2308            | Self::ProjectionInvariant { receipt } => Some(receipt),
2309            #[cfg(feature = "jwt-target-acceptance-review")]
2310            Self::JwtTargetAcceptanceExecution { receipt } => Some(receipt),
2311            #[cfg(feature = "xml-external-entity-review")]
2312            Self::XmlExternalEntityExecution { receipt } => Some(receipt),
2313            _ => None,
2314        }
2315    }
2316}
2317
2318/// Builder for one bounded exact-origin assessment.
2319pub struct WebAssessmentRuntimeBuilder {
2320    target: Url,
2321    limits: WebAssessmentLimits,
2322    http_policy: Option<HttpEvidencePolicy>,
2323    cancellation: CancellationToken,
2324    defense_enforcement: bool,
2325    low_risk_differential_review: bool,
2326    #[cfg(feature = "normalization-resilience")]
2327    normalization_resilience: bool,
2328    #[cfg(feature = "template-evaluation-review")]
2329    template_evaluation_review: bool,
2330    #[cfg(feature = "tls-negotiation-review")]
2331    tls_negotiation_review: bool,
2332    #[cfg(feature = "graphql-review")]
2333    graphql_review: bool,
2334    #[cfg(feature = "authorization-review")]
2335    resource_authorization_review: Option<(AuthorizationReviewPolicy, AuthorizationPrincipalPair)>,
2336    #[cfg(feature = "jwt-target-acceptance-review")]
2337    jwt_target_acceptance_review:
2338        Option<(JwtTargetAcceptancePolicy, JwtTargetAcceptanceRuntimeInput)>,
2339    #[cfg(feature = "openapi-review")]
2340    openapi_review: bool,
2341    #[cfg(feature = "rest-review")]
2342    rest_review: bool,
2343    #[cfg(feature = "ssrf-oast-review")]
2344    ssrf_oast_review: Option<(SsrfOastReviewPolicy, SsrfOastAdminToken)>,
2345    #[cfg(feature = "xml-external-entity-review")]
2346    xml_external_entity_review:
2347        Option<(XmlExternalEntityReviewPolicy, XmlExternalEntityAdminToken)>,
2348    #[cfg(feature = "supplied-session-review")]
2349    supplied_session_review: Option<(SuppliedSessionPolicy, SuppliedSessionRuntimeInput)>,
2350    #[cfg(feature = "wordpress-review")]
2351    wordpress_review: Option<WordPressReviewInputs>,
2352    #[cfg(feature = "wordpress-review")]
2353    wordpress_discovery: bool,
2354    #[cfg(feature = "wordpress-review")]
2355    wordpress_page_scope: Option<super::wordpress_runtime::WordPressPageScope>,
2356    #[cfg(feature = "secret-exposure-review")]
2357    secret_exposure_review: bool,
2358    #[cfg(feature = "tls-observation")]
2359    tls_observation: bool,
2360    #[cfg(feature = "recon-ct-provider")]
2361    recon_ct_provider: Option<ReconCtProviderPolicy>,
2362    #[cfg(feature = "websocket-review")]
2363    websocket_review: Option<WebSocketReviewPolicy>,
2364    #[cfg(all(feature = "websocket-review", feature = "supplied-session-review"))]
2365    websocket_supplied_session: bool,
2366    #[cfg(all(feature = "wordpress-review", feature = "supplied-session-review"))]
2367    wordpress_supplied_session: bool,
2368    root_authorization_context: Option<WebAssessmentRootAuthorizationContext>,
2369}
2370
2371impl WebAssessmentRuntimeBuilder {
2372    pub fn new(target: Url) -> Self {
2373        Self {
2374            target,
2375            limits: WebAssessmentLimits::default(),
2376            http_policy: None,
2377            cancellation: CancellationToken::new(),
2378            defense_enforcement: false,
2379            low_risk_differential_review: false,
2380            #[cfg(feature = "normalization-resilience")]
2381            normalization_resilience: false,
2382            #[cfg(feature = "template-evaluation-review")]
2383            template_evaluation_review: false,
2384            #[cfg(feature = "graphql-review")]
2385            graphql_review: false,
2386            #[cfg(feature = "authorization-review")]
2387            resource_authorization_review: None,
2388            #[cfg(feature = "jwt-target-acceptance-review")]
2389            jwt_target_acceptance_review: None,
2390            #[cfg(feature = "openapi-review")]
2391            openapi_review: false,
2392            #[cfg(feature = "rest-review")]
2393            rest_review: false,
2394            #[cfg(feature = "ssrf-oast-review")]
2395            ssrf_oast_review: None,
2396            #[cfg(feature = "xml-external-entity-review")]
2397            xml_external_entity_review: None,
2398            #[cfg(feature = "supplied-session-review")]
2399            supplied_session_review: None,
2400            #[cfg(feature = "wordpress-review")]
2401            wordpress_review: None,
2402            #[cfg(feature = "wordpress-review")]
2403            wordpress_discovery: false,
2404            #[cfg(feature = "wordpress-review")]
2405            wordpress_page_scope: None,
2406            #[cfg(feature = "secret-exposure-review")]
2407            secret_exposure_review: false,
2408            #[cfg(feature = "tls-observation")]
2409            tls_observation: false,
2410            #[cfg(feature = "tls-negotiation-review")]
2411            tls_negotiation_review: false,
2412            #[cfg(feature = "recon-ct-provider")]
2413            recon_ct_provider: None,
2414            #[cfg(feature = "websocket-review")]
2415            websocket_review: None,
2416            #[cfg(all(feature = "websocket-review", feature = "supplied-session-review"))]
2417            websocket_supplied_session: false,
2418            #[cfg(all(feature = "wordpress-review", feature = "supplied-session-review"))]
2419            wordpress_supplied_session: false,
2420            root_authorization_context: None,
2421        }
2422    }
2423    pub fn limits(mut self, limits: WebAssessmentLimits) -> Self {
2424        self.limits = limits;
2425        self
2426    }
2427    pub fn http_policy(mut self, policy: HttpEvidencePolicy) -> Self {
2428        self.http_policy = Some(policy);
2429        self
2430    }
2431    pub fn cancellation_token(mut self, cancellation: CancellationToken) -> Self {
2432        self.cancellation = cancellation;
2433        self
2434    }
2435    /// Explicitly enables monotonic defense suppression. Default is observation-only.
2436    pub fn enable_defense_enforcement(mut self) -> Self {
2437        self.defense_enforcement = true;
2438        self
2439    }
2440    /// Enables the closed, low-risk matched review catalog after discovery.
2441    ///
2442    /// The default assessment remains discovery/passive only. Enabling this
2443    /// option cannot widen origin authority or create another request budget;
2444    /// every review leg is composed through the assessment's shared broker.
2445    pub fn enable_low_risk_differential_review(mut self) -> Self {
2446        self.low_risk_differential_review = true;
2447        self
2448    }
2449    /// Enables bounded, value-free inspection of already acquired anonymous
2450    /// ordinary GET response bodies. This option schedules no request of its
2451    /// own and does not widen the assessment target or action plan.
2452    #[cfg(feature = "secret-exposure-review")]
2453    pub fn enable_secret_exposure_review(mut self) -> Self {
2454        self.secret_exposure_review = true;
2455        self
2456    }
2457    /// Enables passive observation of TLS already negotiated by the existing
2458    /// assessment transport. This schedules no request or handshake of its own.
2459    #[cfg(feature = "tls-observation")]
2460    pub fn enable_tls_observation(mut self) -> Self {
2461        self.tls_observation = true;
2462        self
2463    }
2464    /// Enables the fixed, bounded TLS 1.3/TLS 1.2 negotiation matrix.
2465    ///
2466    /// This child connects only to the selected HTTPS host/SNI/port, sends no
2467    /// HTTP or application data, and shares the assessment's parent limits.
2468    #[cfg(feature = "tls-negotiation-review")]
2469    pub fn enable_tls_negotiation_review(mut self) -> Self {
2470        self.tls_negotiation_review = true;
2471        self
2472    }
2473    /// Explicitly enables the bounded normalization-resilience child review.
2474    ///
2475    /// This policy is separate from the stable scan-profile wire contract. It
2476    /// can consume only an already-committed, candidate-specific defensive
2477    /// transition from an enabled structural review and shares that review's
2478    /// exact-origin broker, cancellation token, and runtime budget.
2479    #[cfg(feature = "normalization-resilience")]
2480    pub fn enable_normalization_resilience(mut self) -> Self {
2481        self.normalization_resilience = true;
2482        self
2483    }
2484    /// Explicitly enables two independently replayed harmless
2485    /// Jinja-compatible expression pairs inside the established root review.
2486    ///
2487    /// The four legs reuse the root runtime's bootstrap, exact-origin broker,
2488    /// parent budget, cancellation token, and evidence commit. They do not
2489    /// identify a template implementation or authorize operating-system
2490    /// interaction.
2491    #[cfg(feature = "template-evaluation-review")]
2492    pub fn enable_template_evaluation_review(mut self) -> Self {
2493        self.template_evaluation_review = true;
2494        self
2495    }
2496    /// Explicitly enables one bounded, anonymous GraphQL surface review.
2497    ///
2498    /// The child is separate from the stable scan-profile wire contract. It
2499    /// uses only scanner-owned POST bodies, the existing exact-origin broker,
2500    /// and the assessment's shared request and active-verification budget.
2501    #[cfg(feature = "graphql-review")]
2502    pub fn enable_graphql_review(mut self) -> Self {
2503        self.graphql_review = true;
2504        self
2505    }
2506    /// Explicitly enables one exact-origin OpenAPI JSON document/replay review.
2507    #[cfg(feature = "openapi-review")]
2508    pub fn enable_openapi_review(mut self) -> Self {
2509        self.openapi_review = true;
2510        self
2511    }
2512    /// Explicitly enables one OpenAPI-constrained anonymous REST GET/replay.
2513    ///
2514    /// Build rejects this option unless OpenAPI review is enabled for the same
2515    /// assessment. Catalog knowledge constrains selection but grants no
2516    /// independent transport authority.
2517    #[cfg(feature = "rest-review")]
2518    pub fn enable_rest_review(mut self) -> Self {
2519        self.rest_review = true;
2520        self
2521    }
2522    /// Enables one explicitly authorized query-only OAST correlation review.
2523    #[cfg(feature = "ssrf-oast-review")]
2524    pub fn with_ssrf_oast_review(
2525        mut self,
2526        policy: SsrfOastReviewPolicy,
2527        administrator: SsrfOastAdminToken,
2528    ) -> Self {
2529        self.ssrf_oast_review = Some((policy, administrator));
2530        self
2531    }
2532    /// Enables one controlled XML external-entity interaction review.
2533    ///
2534    /// The move-only administrator credential and strict policy are consumed
2535    /// by this assessment. The child can dispatch only the fixed three-body
2536    /// plan through the parent exact-origin broker and one-shot native provider.
2537    #[cfg(feature = "xml-external-entity-review")]
2538    pub fn with_xml_external_entity_review(
2539        mut self,
2540        policy: XmlExternalEntityReviewPolicy,
2541        administrator: XmlExternalEntityAdminToken,
2542    ) -> Self {
2543        self.xml_external_entity_review = Some((policy, administrator));
2544        self
2545    }
2546    /// Enables one bounded, explicitly supplied credential session child.
2547    ///
2548    /// The non-secret policy and move-only credential are consumed by the
2549    /// assessment. Construction still rejects an application mismatch or
2550    /// unprotected non-loopback transport before any credentialed dispatch.
2551    #[cfg(feature = "supplied-session-review")]
2552    pub fn with_supplied_session_review<C>(
2553        mut self,
2554        policy: SuppliedSessionPolicy,
2555        input: C,
2556    ) -> Self
2557    where
2558        C: Into<SuppliedSessionRuntimeInput>,
2559    {
2560        self.supplied_session_review = Some((policy, input.into()));
2561        self
2562    }
2563    /// Enables transport-free WordPress interpretation over the already
2564    /// observed root response and validated local declarations.
2565    #[cfg(feature = "wordpress-review")]
2566    pub fn with_wordpress_review(mut self, inputs: WordPressReviewInputs) -> Self {
2567        self.wordpress_review = Some(inputs);
2568        self
2569    }
2570    /// Explicitly enables bounded public WordPress metadata discovery.
2571    ///
2572    /// Build rejects this selection unless WordPress review is also enabled.
2573    /// Every request uses the assessment's existing exact-origin broker,
2574    /// cancellation token, deadline, and global request/response accounting.
2575    #[cfg(feature = "wordpress-review")]
2576    pub fn with_wordpress_discovery(mut self) -> Self {
2577        self.wordpress_discovery = true;
2578        self
2579    }
2580    /// Extends WordPress metadata discovery across a bounded set of eligible
2581    /// first-hop page representations while preserving the entry-only default.
2582    #[cfg(feature = "wordpress-review")]
2583    pub fn with_wordpress_page_scope(
2584        mut self,
2585        scope: super::wordpress_runtime::WordPressPageScope,
2586    ) -> Self {
2587        self.wordpress_page_scope = Some(scope);
2588        self
2589    }
2590    /// Allows committed supplied-session HTML resources to nominate WordPress
2591    /// metadata under the immutable entry-selected layout.
2592    ///
2593    /// This integration remains opt-in and metadata dispatch stays anonymous.
2594    #[cfg(all(feature = "wordpress-review", feature = "supplied-session-review"))]
2595    pub fn with_wordpress_supplied_session(mut self) -> Self {
2596        self.wordpress_supplied_session = true;
2597        self
2598    }
2599    /// Adds one explicit, bounded Cert Spotter provider query child.
2600    ///
2601    /// The policy grants only the fixed provider operation. Returned names do
2602    /// not enter target planning, and every provider dispatch shares this
2603    /// assessment's request, byte, deadline, and cancellation authority.
2604    #[cfg(feature = "recon-ct-provider")]
2605    pub fn with_recon_ct_provider(mut self, policy: ReconCtProviderPolicy) -> Self {
2606        self.recon_ct_provider = Some(policy);
2607        self
2608    }
2609    /// Adds one explicitly declared anonymous WebSocket exchange.
2610    ///
2611    /// The policy remains a narrowing child of the selected application. It
2612    /// owns no credentials, endpoint discovery, retry, reconnect, or second
2613    /// assessment and is re-bound to the exact root again during build and
2614    /// one-shot authority minting.
2615    #[cfg(feature = "websocket-review")]
2616    pub fn with_websocket_review(mut self, policy: WebSocketReviewPolicy) -> Self {
2617        self.websocket_review = Some(policy);
2618        self
2619    }
2620    /// Binds the selected WebSocket handshake to the one supplied V1
2621    /// Authorization session. Build requires both policies, exactly one
2622    /// protected session resource, and rejects competing WordPress ownership.
2623    #[cfg(all(feature = "websocket-review", feature = "supplied-session-review"))]
2624    pub fn with_websocket_supplied_session(mut self) -> Self {
2625        self.websocket_supplied_session = true;
2626        self
2627    }
2628    /// Adds one explicitly selected, four-view resource authorization review.
2629    ///
2630    /// The policy and move-only principals are consumed by this builder. The
2631    /// child remains part of this assessment's exact-origin authority and
2632    /// cannot create a separately finalized report.
2633    #[cfg(feature = "authorization-review")]
2634    pub fn with_resource_authorization_review(
2635        mut self,
2636        policy: AuthorizationReviewPolicy,
2637        principals: AuthorizationPrincipalPair,
2638    ) -> Self {
2639        self.resource_authorization_review = Some((policy, principals));
2640        self
2641    }
2642    /// Adds one explicitly selected, bounded valid/anonymous/invalid JWT
2643    /// target-acceptance review.
2644    ///
2645    /// The policy, optional locally proven token pair, and typed local
2646    /// ineligibility reason are consumed by this assessment. Build rechecks
2647    /// exact application and protected transport before any token can reach
2648    /// the broker.
2649    #[cfg(feature = "jwt-target-acceptance-review")]
2650    pub fn with_jwt_target_acceptance_review(
2651        mut self,
2652        policy: JwtTargetAcceptancePolicy,
2653        runtime_input: JwtTargetAcceptanceRuntimeInput,
2654    ) -> Self {
2655        self.jwt_target_acceptance_review = Some((policy, runtime_input));
2656        self
2657    }
2658    /// Adds one anonymous/authorized JSON comparison for the exact origin root.
2659    ///
2660    /// The context is consumed during build and can neither be cloned nor
2661    /// serialized. Both requests share the assessment's broker, cancellation,
2662    /// deadline, and global budget.
2663    pub fn with_root_authorization_context(
2664        mut self,
2665        context: WebAssessmentRootAuthorizationContext,
2666    ) -> Self {
2667        self.root_authorization_context = Some(context);
2668        self
2669    }
2670    #[cfg(feature = "secret-exposure-review")]
2671    fn secret_exposure_response_source_conflict(&self) -> bool {
2672        let conflict = false;
2673        #[cfg(all(feature = "openapi-review", feature = "ssrf-oast-review"))]
2674        let conflict = conflict || (self.openapi_review && self.ssrf_oast_review.is_some());
2675        #[cfg(feature = "wordpress-review")]
2676        let conflict = conflict || self.wordpress_discovery;
2677        self.secret_exposure_review && conflict
2678    }
2679    pub fn build(self) -> Result<WebAssessmentRuntime, WebAssessmentRuntimeError> {
2680        #[cfg(feature = "secret-exposure-review")]
2681        if self.secret_exposure_response_source_conflict() {
2682            return Err(WebAssessmentRuntimeError::SecretExposureResponseSourceConflict);
2683        }
2684        #[cfg(all(feature = "xml-external-entity-review", feature = "ssrf-oast-review"))]
2685        if self.xml_external_entity_review.is_some() && self.ssrf_oast_review.is_some() {
2686            return Err(WebAssessmentRuntimeError::XmlExternalEntitySsrfOastConflict);
2687        }
2688        #[cfg(feature = "ssrf-oast-review")]
2689        let ssrf_oast_review = self.ssrf_oast_review.map(|(policy, administrator)| {
2690            let selection = select_observed_query_candidate(
2691                policy.target_origin(),
2692                &self.target,
2693                "authorized-root@1",
2694                true,
2695                true,
2696                true,
2697            );
2698            SsrfOastReviewConfig::new(policy, administrator, selection)
2699        });
2700        #[cfg(feature = "recon-ct-provider")]
2701        if self
2702            .recon_ct_provider
2703            .as_ref()
2704            .is_some_and(|policy| !recon_ct_provider_scope_is_valid(policy, &self.target))
2705        {
2706            return Err(WebAssessmentRuntimeError::ReconCtProviderComposition);
2707        }
2708        #[cfg(feature = "rest-review")]
2709        if self.rest_review && !self.openapi_review {
2710            return Err(WebAssessmentRuntimeError::RestReviewRequiresOpenApiReview);
2711        }
2712        #[cfg(feature = "wordpress-review")]
2713        if self.wordpress_discovery && self.wordpress_review.is_none() {
2714            return Err(WebAssessmentRuntimeError::WordPressDiscoveryRequiresReview);
2715        }
2716        #[cfg(feature = "wordpress-review")]
2717        if self.wordpress_page_scope.is_some() && !self.wordpress_discovery {
2718            return Err(WebAssessmentRuntimeError::WordPressPageScopeRequiresDiscovery);
2719        }
2720        #[cfg(feature = "wordpress-review")]
2721        if self
2722            .wordpress_review
2723            .as_ref()
2724            .is_some_and(|inputs| inputs.discovery_layout().is_some())
2725            && !self.wordpress_discovery
2726        {
2727            return Err(WebAssessmentRuntimeError::WordPressLayoutRequiresDiscovery);
2728        }
2729        #[cfg(feature = "wordpress-review")]
2730        if self
2731            .wordpress_review
2732            .as_ref()
2733            .is_some_and(|inputs| inputs.asset_fingerprint_catalog().is_some())
2734            && !self.wordpress_discovery
2735        {
2736            return Err(WebAssessmentRuntimeError::WordPressAssetFingerprintsRequireDiscovery);
2737        }
2738        #[cfg(all(feature = "wordpress-review", feature = "supplied-session-review"))]
2739        if self.wordpress_supplied_session
2740            && (self.wordpress_review.is_none()
2741                || !self.wordpress_discovery
2742                || self.supplied_session_review.is_none())
2743        {
2744            return Err(WebAssessmentRuntimeError::WordPressSuppliedSessionRequiresInputs);
2745        }
2746        #[cfg(all(feature = "wordpress-review", feature = "supplied-session-review"))]
2747        if self.wordpress_supplied_session
2748            && self
2749                .supplied_session_review
2750                .as_ref()
2751                .is_some_and(|(policy, _)| {
2752                    !matches!(
2753                        policy.version(),
2754                        SuppliedSessionPolicyVersion::V1
2755                            | SuppliedSessionPolicyVersion::V2
2756                            | SuppliedSessionPolicyVersion::V3
2757                    )
2758                })
2759        {
2760            return Err(WebAssessmentRuntimeError::WordPressSuppliedSessionUnsupportedPolicy);
2761        }
2762        #[cfg(all(feature = "websocket-review", feature = "supplied-session-review"))]
2763        if self.websocket_supplied_session
2764            && (self.websocket_review.is_none() || self.supplied_session_review.is_none())
2765        {
2766            return Err(WebAssessmentRuntimeError::WebSocketSuppliedSessionRequiresInputs);
2767        }
2768        #[cfg(all(feature = "websocket-review", feature = "supplied-session-review"))]
2769        if self.websocket_supplied_session
2770            && self
2771                .supplied_session_review
2772                .as_ref()
2773                .is_some_and(|(policy, _)| {
2774                    policy.version() != SuppliedSessionPolicyVersion::V1
2775                        || policy.credential_mechanism()
2776                            != SuppliedSessionCredentialMechanism::AuthorizationHeader
2777                        || policy.resource_count() != 1
2778                })
2779        {
2780            return Err(WebAssessmentRuntimeError::WebSocketSuppliedSessionUnsupportedPolicy);
2781        }
2782        #[cfg(all(
2783            feature = "websocket-review",
2784            feature = "supplied-session-review",
2785            feature = "wordpress-review"
2786        ))]
2787        if self.websocket_supplied_session && self.wordpress_supplied_session {
2788            return Err(WebAssessmentRuntimeError::WebSocketSuppliedSessionWordPressConflict);
2789        }
2790        #[cfg(feature = "wordpress-review")]
2791        if self.wordpress_review.is_some() && !wordpress_application_target_is_valid(&self.target) {
2792            return Err(WebAssessmentRuntimeError::WordPressReviewRequiresOriginRoot);
2793        }
2794        #[cfg(feature = "wordpress-review")]
2795        if self.wordpress_review.is_some() && self.target.path() != "/" && !self.wordpress_discovery
2796        {
2797            return Err(WebAssessmentRuntimeError::WordPressNonRootRequiresDiscovery);
2798        }
2799        #[cfg(feature = "authorization-review")]
2800        if self.root_authorization_context.is_some() && self.resource_authorization_review.is_some()
2801        {
2802            return Err(WebAssessmentRuntimeError::AuthorizationReviewConflict);
2803        }
2804        if self.root_authorization_context.is_some()
2805            && (!self.target.username().is_empty()
2806                || self.target.password().is_some()
2807                || self.target.path() != "/"
2808                || self.target.query().is_some()
2809                || self.target.fragment().is_some())
2810        {
2811            return Err(WebAssessmentRuntimeError::RootAuthorizationContextRequiresOriginRoot);
2812        }
2813        let semantic_limits = assessment_semantic_limits(self.limits)?;
2814        // Validate credentials and the HTTP(S) scheme before removing query
2815        // values from the root. The query-free URL is the only representation
2816        // that is dispatched or retained by this opt-in runtime.
2817        HttpProbe::new(self.target.clone(), HttpProbeMethod::Get)?;
2818        let root = canonicalize_root(&self.target, self.limits)
2819            .ok_or(WebAssessmentRuntimeError::InvalidCanonicalTarget)?;
2820        #[cfg(feature = "tls-negotiation-review")]
2821        if self.tls_negotiation_review && !tls_negotiation_review_target_is_supported(&root.url) {
2822            return Err(WebAssessmentRuntimeError::TlsNegotiationReviewComposition);
2823        }
2824        #[cfg(feature = "supplied-session-review")]
2825        if self
2826            .supplied_session_review
2827            .as_ref()
2828            .is_some_and(|(policy, _)| policy.application_url() != &root.url)
2829        {
2830            return Err(WebAssessmentRuntimeError::SuppliedSessionApplicationMismatch);
2831        }
2832        #[cfg(feature = "websocket-review")]
2833        if self
2834            .websocket_review
2835            .as_ref()
2836            .is_some_and(|policy| !policy.is_bound_to_application(&root.url))
2837        {
2838            return Err(WebAssessmentRuntimeError::WebSocketReviewApplicationMismatch);
2839        }
2840        #[cfg(feature = "xml-external-entity-review")]
2841        if self
2842            .xml_external_entity_review
2843            .as_ref()
2844            .is_some_and(|(policy, _)| !policy.is_bound_to_application(&root.url))
2845        {
2846            return Err(WebAssessmentRuntimeError::XmlExternalEntityApplicationMismatch);
2847        }
2848        #[cfg(feature = "supplied-session-review")]
2849        if self.supplied_session_review.is_some() && !authenticated_transport_is_allowed(&root.url)
2850        {
2851            return Err(WebAssessmentRuntimeError::InsecureSuppliedSessionTransport);
2852        }
2853        #[cfg(feature = "jwt-target-acceptance-review")]
2854        if self
2855            .jwt_target_acceptance_review
2856            .as_ref()
2857            .is_some_and(|(policy, _)| policy.application() != &root.url)
2858        {
2859            return Err(WebAssessmentRuntimeError::JwtTargetAcceptanceApplicationMismatch);
2860        }
2861        #[cfg(feature = "jwt-target-acceptance-review")]
2862        if self
2863            .jwt_target_acceptance_review
2864            .as_ref()
2865            .is_some_and(|(policy, _)| {
2866                !authenticated_transport_is_allowed(&root.url)
2867                    || !authenticated_transport_is_allowed(policy.resource())
2868            })
2869        {
2870            return Err(WebAssessmentRuntimeError::InsecureJwtTargetAcceptanceTransport);
2871        }
2872        #[cfg(feature = "wordpress-review")]
2873        if self
2874            .wordpress_review
2875            .as_ref()
2876            .and_then(WordPressReviewInputs::context)
2877            .is_some_and(|context| context.root_url() != &root.url)
2878        {
2879            return Err(WebAssessmentRuntimeError::WordPressContextRootMismatch);
2880        }
2881        #[cfg(feature = "wordpress-review")]
2882        if self
2883            .wordpress_review
2884            .as_ref()
2885            .and_then(WordPressReviewInputs::discovery_layout)
2886            .is_some_and(|layout| layout.application_url() != &root.url)
2887        {
2888            return Err(WebAssessmentRuntimeError::WordPressContextRootMismatch);
2889        }
2890        #[cfg(feature = "wordpress-review")]
2891        let wordpress_supplied_session = {
2892            #[cfg(feature = "supplied-session-review")]
2893            {
2894                self.wordpress_supplied_session
2895            }
2896            #[cfg(not(feature = "supplied-session-review"))]
2897            {
2898                false
2899            }
2900        };
2901        #[cfg(feature = "wordpress-review")]
2902        let wordpress_review = self.wordpress_review.map(|inputs| {
2903            WordPressReviewBinding::new(
2904                inputs,
2905                self.wordpress_discovery,
2906                self.wordpress_page_scope,
2907                root.url.clone(),
2908                wordpress_supplied_session,
2909            )
2910        });
2911        #[cfg(feature = "authorization-review")]
2912        if self
2913            .resource_authorization_review
2914            .as_ref()
2915            .is_some_and(|(policy, _)| {
2916                !authenticated_transport_is_allowed(&root.url)
2917                    || !authenticated_transport_is_allowed(policy.execution_resource())
2918            })
2919        {
2920            return Err(WebAssessmentRuntimeError::InsecureAuthorizationReviewTransport);
2921        }
2922        if root.url.as_str().len() > self.limits.max_retained_url_bytes() {
2923            return Err(WebAssessmentRuntimeError::RootRetentionLimit);
2924        }
2925        let policy = match self.http_policy {
2926            Some(policy) => policy,
2927            None => HttpEvidencePolicy::for_origin(root.url.clone())?,
2928        }
2929        .restricted_for_web_assessment(&root.url, self.limits.max_response_body_bytes())?;
2930        let discovery_policy = policy.clone();
2931        let optional_active_verifications = {
2932            let allowance = 0_u16;
2933            #[cfg(feature = "graphql-review")]
2934            let allowance = allowance
2935                .checked_add(
2936                    u16::from(
2937                        self.graphql_review
2938                            && self.limits.max_active_verifications()
2939                                == DEFAULT_WEB_ASSESSMENT_MAX_ACTIVE_VERIFICATIONS,
2940                    ) * GRAPHQL_REVIEW_ACTIVE_VERIFICATION_ALLOWANCE,
2941                )
2942                .expect("compiled GraphQL allowance fits u16");
2943            #[cfg(feature = "authorization-review")]
2944            let allowance = allowance
2945                .checked_add(
2946                    u16::from(
2947                        self.resource_authorization_review.is_some()
2948                            && self.limits.max_active_verifications()
2949                                == DEFAULT_WEB_ASSESSMENT_MAX_ACTIVE_VERIFICATIONS,
2950                    ) * AUTHORIZATION_REVIEW_ACTIVE_VERIFICATION_ALLOWANCE,
2951                )
2952                .expect("compiled authorization allowance fits u16");
2953            #[cfg(feature = "template-evaluation-review")]
2954            let allowance = allowance
2955                .checked_add(
2956                    u16::from(
2957                        self.template_evaluation_review
2958                            && self.limits.max_active_verifications()
2959                                == DEFAULT_WEB_ASSESSMENT_MAX_ACTIVE_VERIFICATIONS,
2960                    ) * TEMPLATE_EVALUATION_ACTIVE_VERIFICATION_ALLOWANCE,
2961                )
2962                .expect("compiled template-evaluation allowance fits u16");
2963            #[cfg(feature = "jwt-target-acceptance-review")]
2964            let allowance = allowance
2965                .checked_add(
2966                    u16::from(
2967                        self.jwt_target_acceptance_review.is_some()
2968                            && self.limits.max_active_verifications()
2969                                == DEFAULT_WEB_ASSESSMENT_MAX_ACTIVE_VERIFICATIONS,
2970                    ) * u16::from(MAX_JWT_TARGET_ACCEPTANCE_ACTIVE_REQUESTS),
2971                )
2972                .expect("compiled JWT target-acceptance allowance fits u16");
2973            #[cfg(feature = "openapi-review")]
2974            let allowance = allowance
2975                .checked_add(
2976                    u16::from(
2977                        self.openapi_review
2978                            && self.limits.max_active_verifications()
2979                                == DEFAULT_WEB_ASSESSMENT_MAX_ACTIVE_VERIFICATIONS,
2980                    ) * u16::try_from(MAX_OPENAPI_REVIEW_ACTIVE_VERIFICATIONS)
2981                        .expect("OpenAPI active-verification allowance fits u16"),
2982                )
2983                .expect("compiled OpenAPI allowance fits u16");
2984            #[cfg(feature = "rest-review")]
2985            let allowance = allowance
2986                .checked_add(
2987                    u16::from(
2988                        self.rest_review
2989                            && self.limits.max_active_verifications()
2990                                == DEFAULT_WEB_ASSESSMENT_MAX_ACTIVE_VERIFICATIONS,
2991                    ) * u16::try_from(MAX_REST_REVIEW_ACTIVE_VERIFICATIONS)
2992                        .expect("REST active-verification allowance fits u16"),
2993                )
2994                .expect("compiled REST allowance fits u16");
2995            #[cfg(feature = "ssrf-oast-review")]
2996            let allowance = allowance
2997                .checked_add(
2998                    u16::from(
2999                        ssrf_oast_review.is_some()
3000                            && self.limits.max_active_verifications()
3001                                == DEFAULT_WEB_ASSESSMENT_MAX_ACTIVE_VERIFICATIONS,
3002                    ) * u16::try_from(MAX_SSRF_OAST_REVIEW_ACTIVE_VERIFICATIONS)
3003                        .expect("SSRF OAST active-verification allowance fits u16"),
3004                )
3005                .expect("compiled SSRF OAST allowance fits u16");
3006            #[cfg(feature = "xml-external-entity-review")]
3007            let allowance = allowance
3008                .checked_add(
3009                    u16::from(
3010                        self.xml_external_entity_review.is_some()
3011                            && self.limits.max_active_verifications()
3012                                == DEFAULT_WEB_ASSESSMENT_MAX_ACTIVE_VERIFICATIONS,
3013                    ) * u16::try_from(XML_EXTERNAL_ENTITY_ACTIVE_VERIFICATIONS)
3014                        .expect("controlled XML active-verification allowance fits u16"),
3015                )
3016                .expect("compiled controlled XML allowance fits u16");
3017            #[cfg(feature = "tls-negotiation-review")]
3018            let allowance = allowance
3019                .checked_add(
3020                    u16::from(
3021                        self.tls_negotiation_review
3022                            && self.limits.max_active_verifications()
3023                                == DEFAULT_WEB_ASSESSMENT_MAX_ACTIVE_VERIFICATIONS,
3024                    ) * TLS_NEGOTIATION_ACTIVE_VERIFICATION_ALLOWANCE,
3025                )
3026                .expect("compiled active TLS negotiation allowance fits u16");
3027            allowance
3028        };
3029        let runtime_active_verification_limit = self
3030            .limits
3031            .max_active_verifications()
3032            .checked_add(optional_active_verifications)
3033            .expect("compiled optional active-verification allowances fit u16");
3034        #[cfg(feature = "xml-external-entity-owned-https-test-profile")]
3035        let owned_xml_https_test_profile =
3036            self.xml_external_entity_review
3037                .as_ref()
3038                .and_then(|(policy, _)| {
3039                    OwnedXmlHttpsTestTransportProfile::for_application_and_policy(&root.url, policy)
3040                });
3041        #[cfg(all(
3042            feature = "tls-observation",
3043            feature = "xml-external-entity-owned-https-test-profile"
3044        ))]
3045        let authority = match (self.tls_observation, owned_xml_https_test_profile) {
3046            (true, Some(profile)) => SharedWebRuntimeAuthority::new_exact_origin_with_tls_observation_and_owned_xml_https_test_profile(
3047                &root.url,
3048                policy,
3049                self.limits.runtime_budget(optional_active_verifications),
3050                self.cancellation,
3051                profile,
3052            )?,
3053            (false, Some(profile)) => SharedWebRuntimeAuthority::new_exact_origin_with_owned_xml_https_test_profile(
3054                &root.url,
3055                policy,
3056                self.limits.runtime_budget(optional_active_verifications),
3057                self.cancellation,
3058                profile,
3059            )?,
3060            (true, None) => SharedWebRuntimeAuthority::new_exact_origin_with_tls_observation(
3061                &root.url,
3062                policy,
3063                self.limits.runtime_budget(optional_active_verifications),
3064                self.cancellation,
3065            )?,
3066            (false, None) => SharedWebRuntimeAuthority::new_exact_origin(
3067                &root.url,
3068                policy,
3069                self.limits.runtime_budget(optional_active_verifications),
3070                self.cancellation,
3071            )?,
3072        };
3073        #[cfg(all(
3074            feature = "tls-observation",
3075            not(feature = "xml-external-entity-owned-https-test-profile")
3076        ))]
3077        let authority = if self.tls_observation {
3078            SharedWebRuntimeAuthority::new_exact_origin_with_tls_observation(
3079                &root.url,
3080                policy,
3081                self.limits.runtime_budget(optional_active_verifications),
3082                self.cancellation,
3083            )?
3084        } else {
3085            SharedWebRuntimeAuthority::new_exact_origin(
3086                &root.url,
3087                policy,
3088                self.limits.runtime_budget(optional_active_verifications),
3089                self.cancellation,
3090            )?
3091        };
3092        #[cfg(all(
3093            not(feature = "tls-observation"),
3094            feature = "xml-external-entity-owned-https-test-profile"
3095        ))]
3096        let authority = match owned_xml_https_test_profile {
3097            Some(profile) => {
3098                SharedWebRuntimeAuthority::new_exact_origin_with_owned_xml_https_test_profile(
3099                    &root.url,
3100                    policy,
3101                    self.limits.runtime_budget(optional_active_verifications),
3102                    self.cancellation,
3103                    profile,
3104                )?
3105            },
3106            None => SharedWebRuntimeAuthority::new_exact_origin(
3107                &root.url,
3108                policy,
3109                self.limits.runtime_budget(optional_active_verifications),
3110                self.cancellation,
3111            )?,
3112        };
3113        #[cfg(all(
3114            not(feature = "tls-observation"),
3115            not(feature = "xml-external-entity-owned-https-test-profile")
3116        ))]
3117        let authority = SharedWebRuntimeAuthority::new_exact_origin(
3118            &root.url,
3119            policy,
3120            self.limits.runtime_budget(optional_active_verifications),
3121            self.cancellation,
3122        )?;
3123        #[cfg(feature = "xml-external-entity-review")]
3124        let xml_external_entity_review = self
3125            .xml_external_entity_review
3126            .map(|(policy, administrator)| {
3127                authority
3128                    .authorize_target(policy.endpoint())
3129                    .map_err(|_| WebAssessmentRuntimeError::XmlExternalEntityApplicationMismatch)?;
3130                Ok::<_, WebAssessmentRuntimeError>(XmlExternalEntityRuntimeConfig::new(
3131                    policy,
3132                    administrator,
3133                ))
3134            })
3135            .transpose()?;
3136        #[cfg(feature = "supplied-session-review")]
3137        let supplied_session_review = self
3138            .supplied_session_review
3139            .map(|(policy, credential)| {
3140                SuppliedSessionRuntimeConfig::new(policy, credential, &authority)
3141                    .map_err(|()| WebAssessmentRuntimeError::SuppliedSessionComposition)
3142            })
3143            .transpose()?;
3144        #[cfg(feature = "jwt-target-acceptance-review")]
3145        let jwt_target_acceptance_review = self
3146            .jwt_target_acceptance_review
3147            .map(|(policy, runtime_input)| {
3148                JwtTargetAcceptanceRuntimeConfig::new(policy, runtime_input)
3149                    .map_err(|_| WebAssessmentRuntimeError::JwtTargetAcceptanceComposition)
3150            })
3151            .transpose()?;
3152        let root_subject = WebAssessmentSubject {
3153            url: root.url.clone(),
3154            method: WebAssessmentMethod::Get,
3155            depth: 0,
3156            origin: WebAssessmentSubjectOrigin::AuthorizedRoot,
3157            query_parameter_names: root.query_parameter_names,
3158            evidence_ids: Vec::new(),
3159        };
3160        let root_api_visibility = self
3161            .root_authorization_context
3162            .map(|context| {
3163                let resource_scope = EntityId::new(format!("endpoint:{}", root.url))
3164                    .map_err(|_| WebAssessmentRuntimeError::ApiVisibilityComposition)?;
3165                build_root_api_visibility_runtime(
3166                    &root.url,
3167                    resource_scope,
3168                    authority.clone(),
3169                    context,
3170                )
3171                .map_err(|_| WebAssessmentRuntimeError::ApiVisibilityComposition)
3172            })
3173            .transpose()?;
3174        #[cfg(feature = "authorization-review")]
3175        let resource_authorization_review =
3176            self.resource_authorization_review
3177                .map(|(policy, principals)| {
3178                    let config = ResourceAuthorizationReviewConfig::new(policy, principals);
3179                    #[cfg(feature = "secret-exposure-review")]
3180                    let config = if self.secret_exposure_review {
3181                        config.with_value_free_response_privacy()
3182                    } else {
3183                        config
3184                    };
3185                    config
3186                });
3187        let native_review = if self.low_risk_differential_review {
3188            let seeds = NativeWebReviewSeeds::from_authorized_origin(&root.url)?;
3189            let redirect_query_parameter =
3190                select_redirect_review_query_parameter(&root_subject.query_parameter_names);
3191            let sql_query_parameter =
3192                select_sql_review_query_parameter(&root_subject.query_parameter_names);
3193            let ssti_query_parameter =
3194                select_ssti_review_query_parameter(&root_subject.query_parameter_names);
3195            #[cfg(feature = "template-evaluation-review")]
3196            let template_evaluation_query_parameter = self
3197                .template_evaluation_review
3198                .then(|| ssti_query_parameter.clone())
3199                .flatten();
3200            let reflection_query_parameter =
3201                select_reflection_review_query_parameter(&root_subject.query_parameter_names);
3202            let observer = AssessmentReviewObserverSet::new_with_sql(
3203                root.url.clone(),
3204                seeds.clone(),
3205                redirect_query_parameter.as_deref(),
3206                reflection_query_parameter.as_deref(),
3207                sql_query_parameter.as_deref(),
3208                ssti_query_parameter.as_deref(),
3209            )
3210            .map_err(|_| WebAssessmentRuntimeError::NativeReviewComposition)?;
3211            #[cfg(feature = "template-evaluation-review")]
3212            let observer = match template_evaluation_query_parameter.as_deref() {
3213                Some(parameter) => observer
3214                    .with_template_evaluation(parameter)
3215                    .map_err(|_| WebAssessmentRuntimeError::NativeReviewComposition)?,
3216                None => observer,
3217            };
3218            #[cfg(feature = "secret-exposure-review")]
3219            let observer = if self.secret_exposure_review {
3220                observer.with_secret_exposure_privacy_guard()
3221            } else {
3222                observer
3223            };
3224            let observer = Arc::new(observer);
3225            let ledger = CommittedAssessmentReviewLedger::new_with_sql(
3226                root.url.clone(),
3227                seeds.clone(),
3228                redirect_query_parameter.as_deref(),
3229                reflection_query_parameter.as_deref(),
3230                sql_query_parameter.as_deref(),
3231                ssti_query_parameter.as_deref(),
3232            )
3233            .map_err(|_| WebAssessmentRuntimeError::NativeReviewComposition)?;
3234            #[cfg(feature = "template-evaluation-review")]
3235            let ledger = match template_evaluation_query_parameter.as_deref() {
3236                Some(parameter) => ledger
3237                    .with_template_evaluation(parameter)
3238                    .map_err(|_| WebAssessmentRuntimeError::NativeReviewComposition)?,
3239                None => ledger,
3240            };
3241            #[cfg(feature = "template-evaluation-review")]
3242            let enabled_actions = enabled_native_web_review_actions_with_template(
3243                true,
3244                redirect_query_parameter.is_some(),
3245                reflection_query_parameter.is_some(),
3246                sql_query_parameter.is_some(),
3247                ssti_query_parameter.is_some(),
3248                template_evaluation_query_parameter.is_some(),
3249                None,
3250            );
3251            #[cfg(not(feature = "template-evaluation-review"))]
3252            let enabled_actions = enabled_native_web_review_actions(
3253                true,
3254                redirect_query_parameter.is_some(),
3255                reflection_query_parameter.is_some(),
3256                sql_query_parameter.is_some(),
3257                ssti_query_parameter.is_some(),
3258                None,
3259            );
3260            Some(AssessmentNativeReviewRuntime {
3261                target: root.url.clone(),
3262                seeds,
3263                enabled_actions,
3264                redirect_query_parameter,
3265                reflection_query_parameter,
3266                sql_query_parameter,
3267                ssti_query_parameter,
3268                #[cfg(feature = "template-evaluation-review")]
3269                template_evaluation_query_parameter,
3270                observer,
3271                ledger,
3272            })
3273        } else {
3274            None
3275        };
3276        let ledger = AssessmentLedger::new(&root_subject);
3277        let mut initial_reasons = BTreeSet::new();
3278        if root.query_name_limit_reached {
3279            initial_reasons.insert(WebAssessmentIncompleteReason::QueryParameterNameLimit);
3280        }
3281        Ok(WebAssessmentRuntime {
3282            limits: self.limits,
3283            runtime_active_verification_limit,
3284            runtime_optional_active_verification_allowance: optional_active_verifications,
3285            semantic_limits,
3286            semantic_evidence: AssessmentSemanticEvidence::default(),
3287            authority,
3288            discovery_policy,
3289            ledger,
3290            root: root_subject,
3291            initial_reasons,
3292            started: false,
3293            progress: None,
3294            defense_enforcement: self.defense_enforcement,
3295            #[cfg(feature = "normalization-resilience")]
3296            normalization_resilience: self.normalization_resilience,
3297            #[cfg(feature = "template-evaluation-review")]
3298            template_evaluation_review: self.template_evaluation_review,
3299            #[cfg(feature = "tls-negotiation-review")]
3300            tls_negotiation_review: self.tls_negotiation_review,
3301            #[cfg(feature = "graphql-review")]
3302            graphql_review: self.graphql_review,
3303            #[cfg(feature = "authorization-review")]
3304            resource_authorization_review,
3305            #[cfg(feature = "jwt-target-acceptance-review")]
3306            jwt_target_acceptance_review,
3307            #[cfg(feature = "openapi-review")]
3308            openapi_review: self.openapi_review.then(|| {
3309                let config = OpenApiReviewConfig::new(
3310                    select_openapi_candidate(&root.url, []).expect("fixed exact-origin fallback"),
3311                );
3312                #[cfg(feature = "secret-exposure-review")]
3313                let config = if self.secret_exposure_review {
3314                    config.with_value_free_response_privacy()
3315                } else {
3316                    config
3317                };
3318                config
3319            }),
3320            #[cfg(feature = "rest-review")]
3321            rest_review: self.rest_review,
3322            #[cfg(feature = "ssrf-oast-review")]
3323            ssrf_oast_review,
3324            #[cfg(feature = "xml-external-entity-review")]
3325            xml_external_entity_review,
3326            #[cfg(feature = "supplied-session-review")]
3327            supplied_session_review,
3328            #[cfg(feature = "wordpress-review")]
3329            wordpress_review,
3330            #[cfg(feature = "recon-ct-provider")]
3331            recon_ct_provider: self.recon_ct_provider,
3332            #[cfg(feature = "websocket-review")]
3333            websocket_review: self.websocket_review,
3334            #[cfg(all(feature = "websocket-review", feature = "supplied-session-review"))]
3335            websocket_supplied_session: self.websocket_supplied_session,
3336            #[cfg(feature = "secret-exposure-review")]
3337            secret_exposure_collector: self
3338                .secret_exposure_review
3339                .then(SecretExposureCollector::default),
3340            #[cfg(feature = "secret-exposure-review")]
3341            secret_exposure_ledger: CommittedSecretExposureLedger::default(),
3342            #[cfg(all(feature = "wordpress-review", feature = "supplied-session-review"))]
3343            wordpress_supplied_session,
3344            native_review,
3345            non_root_structural_review: None,
3346            xss_structural_review: None,
3347            #[cfg(feature = "normalization-resilience")]
3348            normalization_review: None,
3349            #[cfg(feature = "template-evaluation-review")]
3350            template_evaluation_review_audit: None,
3351            #[cfg(feature = "tls-negotiation-review")]
3352            tls_negotiation_review_audit: None,
3353            root_api_visibility,
3354            committed_api_visibility: None,
3355            #[cfg(feature = "graphql-review")]
3356            committed_graphql_review: None,
3357            #[cfg(feature = "authorization-review")]
3358            committed_resource_authorization_review: None,
3359            #[cfg(feature = "authorization-review")]
3360            authorization_review_audit: None,
3361            #[cfg(feature = "jwt-target-acceptance-review")]
3362            jwt_target_acceptance_audit: None,
3363            #[cfg(feature = "openapi-review")]
3364            committed_openapi_review: None,
3365            #[cfg(feature = "openapi-review")]
3366            openapi_review_audit: None,
3367            #[cfg(feature = "rest-review")]
3368            committed_rest_review: None,
3369            #[cfg(feature = "rest-review")]
3370            rest_review_audit: None,
3371            #[cfg(feature = "ssrf-oast-review")]
3372            committed_ssrf_oast_review: None,
3373            #[cfg(feature = "ssrf-oast-review")]
3374            ssrf_oast_review_audit: None,
3375            #[cfg(feature = "xml-external-entity-review")]
3376            committed_xml_external_entity_review: None,
3377            #[cfg(feature = "xml-external-entity-review")]
3378            xml_external_entity_review_audit: None,
3379            #[cfg(feature = "supplied-session-review")]
3380            supplied_session_audit: None,
3381            #[cfg(feature = "websocket-review")]
3382            websocket_review_audit: None,
3383            #[cfg(feature = "wordpress-review")]
3384            committed_wordpress_review: None,
3385            #[cfg(feature = "graphql-review")]
3386            optional_child_parent_defense: None,
3387            defense_audit: WebAssessmentDefenseAudit::new(if self.defense_enforcement {
3388                WebAssessmentDefenseMode::Enforced
3389            } else {
3390                WebAssessmentDefenseMode::ObservationOnly
3391            }),
3392            passive_ledger: CommittedAssessmentPassiveLedger::default(),
3393        })
3394    }
3395}
3396
3397/// Single-use deterministic assessment of one authorized exact origin.
3398pub struct WebAssessmentRuntime {
3399    limits: WebAssessmentLimits,
3400    runtime_active_verification_limit: u16,
3401    runtime_optional_active_verification_allowance: u16,
3402    semantic_limits: SemanticExtractionLimits,
3403    semantic_evidence: AssessmentSemanticEvidence,
3404    authority: SharedWebRuntimeAuthority,
3405    discovery_policy: HttpEvidencePolicy,
3406    ledger: AssessmentLedger,
3407    root: WebAssessmentSubject,
3408    initial_reasons: BTreeSet<WebAssessmentIncompleteReason>,
3409    started: bool,
3410    progress: Option<watch::Sender<Option<WebAssessmentProgressSnapshot>>>,
3411    defense_enforcement: bool,
3412    #[cfg(feature = "normalization-resilience")]
3413    normalization_resilience: bool,
3414    #[cfg(feature = "template-evaluation-review")]
3415    template_evaluation_review: bool,
3416    #[cfg(feature = "tls-negotiation-review")]
3417    tls_negotiation_review: bool,
3418    #[cfg(feature = "graphql-review")]
3419    graphql_review: bool,
3420    #[cfg(feature = "authorization-review")]
3421    resource_authorization_review: Option<ResourceAuthorizationReviewConfig>,
3422    #[cfg(feature = "jwt-target-acceptance-review")]
3423    jwt_target_acceptance_review: Option<JwtTargetAcceptanceRuntimeConfig>,
3424    #[cfg(feature = "openapi-review")]
3425    openapi_review: Option<OpenApiReviewConfig>,
3426    #[cfg(feature = "rest-review")]
3427    rest_review: bool,
3428    #[cfg(feature = "ssrf-oast-review")]
3429    ssrf_oast_review: Option<SsrfOastReviewConfig>,
3430    #[cfg(feature = "xml-external-entity-review")]
3431    xml_external_entity_review: Option<XmlExternalEntityRuntimeConfig>,
3432    #[cfg(feature = "supplied-session-review")]
3433    supplied_session_review: Option<SuppliedSessionRuntimeConfig>,
3434    #[cfg(feature = "wordpress-review")]
3435    wordpress_review: Option<WordPressReviewBinding>,
3436    #[cfg(feature = "recon-ct-provider")]
3437    recon_ct_provider: Option<ReconCtProviderPolicy>,
3438    #[cfg(feature = "websocket-review")]
3439    websocket_review: Option<WebSocketReviewPolicy>,
3440    #[cfg(all(feature = "websocket-review", feature = "supplied-session-review"))]
3441    websocket_supplied_session: bool,
3442    #[cfg(feature = "secret-exposure-review")]
3443    secret_exposure_collector: Option<SecretExposureCollector>,
3444    #[cfg(feature = "secret-exposure-review")]
3445    secret_exposure_ledger: CommittedSecretExposureLedger,
3446    #[cfg(all(feature = "wordpress-review", feature = "supplied-session-review"))]
3447    wordpress_supplied_session: bool,
3448    native_review: Option<AssessmentNativeReviewRuntime>,
3449    non_root_structural_review: Option<AssessmentNativeReviewRuntime>,
3450    xss_structural_review: Option<AssessmentNativeReviewRuntime>,
3451    #[cfg(feature = "normalization-resilience")]
3452    normalization_review: Option<AssessmentNativeReviewRuntime>,
3453    #[cfg(feature = "template-evaluation-review")]
3454    template_evaluation_review_audit: Option<TemplateEvaluationReviewAudit>,
3455    #[cfg(feature = "tls-negotiation-review")]
3456    tls_negotiation_review_audit: Option<WebAssessmentTlsNegotiationReviewAudit>,
3457    root_api_visibility: Option<RootApiVisibilityRuntime>,
3458    committed_api_visibility: Option<CommittedAssessmentApiVisibility>,
3459    #[cfg(feature = "graphql-review")]
3460    committed_graphql_review: Option<CommittedGraphqlReview>,
3461    #[cfg(feature = "authorization-review")]
3462    committed_resource_authorization_review: Option<CommittedResourceAuthorizationReview>,
3463    #[cfg(feature = "openapi-review")]
3464    committed_openapi_review: Option<CommittedOpenApiReview>,
3465    #[cfg(feature = "authorization-review")]
3466    authorization_review_audit: Option<WebAssessmentAuthorizationAudit>,
3467    #[cfg(feature = "jwt-target-acceptance-review")]
3468    jwt_target_acceptance_audit: Option<JwtTargetAcceptanceAudit>,
3469    #[cfg(feature = "openapi-review")]
3470    openapi_review_audit: Option<WebAssessmentOpenApiAudit>,
3471    #[cfg(feature = "rest-review")]
3472    committed_rest_review: Option<CommittedRestReview>,
3473    #[cfg(feature = "rest-review")]
3474    rest_review_audit: Option<WebAssessmentRestAudit>,
3475    #[cfg(feature = "ssrf-oast-review")]
3476    committed_ssrf_oast_review: Option<CommittedSsrfOastReview>,
3477    #[cfg(feature = "ssrf-oast-review")]
3478    ssrf_oast_review_audit: Option<WebAssessmentSsrfOastAudit>,
3479    #[cfg(feature = "xml-external-entity-review")]
3480    committed_xml_external_entity_review: Option<CommittedXmlExternalEntityReview>,
3481    #[cfg(feature = "xml-external-entity-review")]
3482    xml_external_entity_review_audit: Option<XmlExternalEntityReviewAudit>,
3483    #[cfg(feature = "supplied-session-review")]
3484    supplied_session_audit: Option<WebAssessmentSuppliedSessionAudit>,
3485    #[cfg(feature = "websocket-review")]
3486    websocket_review_audit: Option<WebAssessmentWebSocketReviewAudit>,
3487    #[cfg(feature = "wordpress-review")]
3488    committed_wordpress_review: Option<CommittedWordPressReview>,
3489    #[cfg(feature = "graphql-review")]
3490    optional_child_parent_defense: Option<AssessmentDefenseController>,
3491    defense_audit: WebAssessmentDefenseAudit,
3492    passive_ledger: CommittedAssessmentPassiveLedger,
3493}
3494
3495struct AssessmentNativeReviewRuntime {
3496    target: Url,
3497    seeds: NativeWebReviewSeeds,
3498    redirect_query_parameter: Option<String>,
3499    reflection_query_parameter: Option<String>,
3500    sql_query_parameter: Option<String>,
3501    ssti_query_parameter: Option<String>,
3502    #[cfg(feature = "template-evaluation-review")]
3503    template_evaluation_query_parameter: Option<String>,
3504    observer: Arc<AssessmentReviewObserverSet>,
3505    ledger: CommittedAssessmentReviewLedger,
3506    enabled_actions: Vec<NativeWebReviewActionKind>,
3507}
3508
3509struct FailedSubjectBoundary {
3510    subject: WebAssessmentSubject,
3511    envelope: AssessmentEnvelope,
3512    source: StandardWebDecisionRuntimeError,
3513    started: bool,
3514    defense: Option<AssessmentDefenseController>,
3515    planner: Option<crate::AttackPlanner>,
3516}
3517
3518fn receipt_requires_defense_projection(receipt: &DecisionEvidenceReceipt) -> bool {
3519    receipt.case().action_id() != StandardWebActionKind::LaravelInputAnalysis.action_id()
3520}
3521
3522fn replay_defense_parts(
3523    bootstrap: Option<&DecisionEvidenceReceipt>,
3524    turns: &[StandardWebDecisionRuntimeTurn],
3525    unverified: Option<&DecisionEvidenceReceipt>,
3526    planner: &crate::AttackPlanner,
3527    knowledge: &KnowledgeBase,
3528    enforcement_enabled: bool,
3529) -> Result<AssessmentDefenseController, ()> {
3530    let mut replay = AssessmentDefenseController::new(enforcement_enabled);
3531    if let Some(receipt) = bootstrap {
3532        replay.ingest_receipt(receipt, knowledge, true)?;
3533    }
3534    for turn in turns {
3535        match turn {
3536            StandardWebDecisionRuntimeTurn::Planning(report) => {
3537                replay.record_shadow(report, planner)?;
3538            },
3539            StandardWebDecisionRuntimeTurn::Outcome { evidence, .. } => {
3540                replay.ingest_receipt(
3541                    evidence,
3542                    knowledge,
3543                    receipt_requires_defense_projection(evidence),
3544                )?;
3545            },
3546        }
3547    }
3548    if let Some(receipt) = unverified {
3549        replay.ingest_receipt(
3550            receipt,
3551            knowledge,
3552            receipt_requires_defense_projection(receipt),
3553        )?;
3554    }
3555    Ok(replay)
3556}
3557
3558fn replay_standard_defense(
3559    report: &crate::StandardWebDecisionRunReport,
3560    planner: &crate::AttackPlanner,
3561    knowledge: &KnowledgeBase,
3562    enforcement_enabled: bool,
3563) -> Result<AssessmentDefenseController, ()> {
3564    replay_defense_parts(
3565        report.bootstrap(),
3566        report.turns(),
3567        report.unverified_evidence(),
3568        planner,
3569        knowledge,
3570        enforcement_enabled,
3571    )
3572}
3573
3574#[cfg(test)]
3575fn is_native_review_action(action_id: &str) -> bool {
3576    NativeWebReviewActionKind::all()
3577        .into_iter()
3578        .any(|kind| kind.action_id() == action_id)
3579}
3580
3581fn replay_native_review(
3582    report: &crate::StandardWebDecisionRunReport,
3583    review: &mut AssessmentNativeReviewRuntime,
3584    knowledge: &KnowledgeBase,
3585) -> Result<bool, ()> {
3586    for turn in report.turns() {
3587        if let StandardWebDecisionRuntimeTurn::Outcome { evidence, decision } = turn {
3588            if review
3589                .enabled_actions
3590                .iter()
3591                .any(|kind| kind.action_id() == evidence.case().action_id())
3592            {
3593                review
3594                    .ledger
3595                    .ingest_outcome(evidence, decision, knowledge)
3596                    .map_err(|_| ())?;
3597            }
3598        }
3599    }
3600
3601    let enabled_complete = review
3602        .enabled_actions
3603        .iter()
3604        .copied()
3605        .all(|kind| review.ledger.pair_is_complete(kind));
3606    let expected_observations = review.enabled_actions.len() * 2;
3607    Ok(enabled_complete
3608        && !review.ledger.has_incomplete_reflection_observation()
3609        && !review.ledger.has_incomplete_sql_observation()
3610        && !review.ledger.has_incomplete_ssti_observation()
3611        && review.ledger.observations().len() == expected_observations)
3612}
3613
3614#[cfg(feature = "template-evaluation-review")]
3615fn template_evaluation_audit_from_committed_runtime(
3616    review: Option<&AssessmentNativeReviewRuntime>,
3617    transport: &TransportDispatchAudit,
3618) -> Result<TemplateEvaluationReviewAudit, ()> {
3619    if transport.omitted_receipt_count() != 0 {
3620        return Err(());
3621    }
3622    let primary = NativeWebReviewActionKind::TemplateJinjaUppercaseQueryPair.action_id();
3623    let replay = NativeWebReviewActionKind::TemplateJinjaUppercaseQueryReplayPair.action_id();
3624    let receipts = transport
3625        .receipts()
3626        .iter()
3627        .filter(|receipt| {
3628            let action = receipt.action_id();
3629            action == primary || action == replay
3630        })
3631        .collect::<Vec<_>>();
3632    if receipts.len() > usize::from(TEMPLATE_EVALUATION_MAX_REQUESTS) {
3633        return Err(());
3634    }
3635    let attempted_request_count = u8::try_from(receipts.len()).map_err(|_| ())?;
3636    let active_request_count = u8::try_from(
3637        receipts
3638            .iter()
3639            .filter(|receipt| receipt.stage() == DecisionExecutionStage::Active)
3640            .count(),
3641    )
3642    .map_err(|_| ())?;
3643    let completed_response_count = u8::try_from(
3644        receipts
3645            .iter()
3646            .filter(|receipt| receipt.outcome() == TransportDispatchOutcome::Completed)
3647            .count(),
3648    )
3649    .map_err(|_| ())?;
3650
3651    let summary = review.and_then(|review| review.ledger.template_evaluation_summary());
3652    let facts = match summary {
3653        Some(summary) => TemplateEvaluationReviewAuditFacts {
3654            outcome: summary.outcome(),
3655            selected_case_count: TEMPLATE_EVALUATION_SELECTED_CASES,
3656            attempted_request_count,
3657            active_request_count,
3658            completed_response_count,
3659            committed_response_count: summary.committed_response_count(),
3660            projected_item_count: summary.projected_item_count(),
3661        },
3662        None if receipts.is_empty() => TemplateEvaluationReviewAuditFacts {
3663            outcome: TemplateEvaluationReviewOutcome::ParentNotObserved,
3664            selected_case_count: 0,
3665            attempted_request_count: 0,
3666            active_request_count: 0,
3667            completed_response_count: 0,
3668            committed_response_count: 0,
3669            projected_item_count: 0,
3670        },
3671        None => return Err(()),
3672    };
3673    TemplateEvaluationReviewAudit::from_runtime(facts).map_err(|_| ())
3674}
3675
3676impl WebAssessmentRuntime {
3677    pub fn builder(target: Url) -> WebAssessmentRuntimeBuilder {
3678        WebAssessmentRuntimeBuilder::new(target)
3679    }
3680    pub fn limits(&self) -> WebAssessmentLimits {
3681        self.limits
3682    }
3683    pub fn authorized_root(&self) -> &WebAssessmentSubject {
3684        &self.root
3685    }
3686    pub fn knowledge(&self) -> &KnowledgeBase {
3687        self.authority.knowledge()
3688    }
3689    pub fn cancellation_token(&self) -> CancellationToken {
3690        self.authority.cancellation_token()
3691    }
3692    pub fn has_started(&self) -> bool {
3693        self.started
3694    }
3695
3696    /// Creates the single read-only progress observer before execution starts.
3697    ///
3698    /// `None` means the runtime already started or an observer was already
3699    /// created. Calling this method is the only path that allocates progress
3700    /// state; ordinary assessments carry no progress buffer or channel.
3701    pub fn progress_observer(&mut self) -> Option<WebAssessmentProgressObserver> {
3702        if self.started || self.progress.is_some() {
3703            return None;
3704        }
3705        let (sender, receiver) = watch::channel(None);
3706        self.progress = Some(sender);
3707        Some(WebAssessmentProgressObserver { receiver })
3708    }
3709
3710    fn publish_progress(
3711        &self,
3712        checkpoint: WebAssessmentProgressCheckpoint,
3713        executed_subjects: usize,
3714        subjects_processed: usize,
3715    ) {
3716        let Some(progress) = &self.progress else {
3717            return;
3718        };
3719        let accounting = self.authority.request_accounting().snapshot();
3720        progress.send_replace(Some(WebAssessmentProgressSnapshot {
3721            checkpoint,
3722            executed_subjects,
3723            subjects_processed,
3724            requests_accounted: accounting.total_requests(),
3725            active_verifications_accounted: accounting.active_verifications(),
3726        }));
3727    }
3728
3729    pub async fn analyze(&mut self) -> Result<WebAssessmentRunReport, WebAssessmentRuntimeError> {
3730        if self.started {
3731            return Err(WebAssessmentRuntimeError::AlreadyStarted);
3732        }
3733        self.started = true;
3734        let child_authority = self.authority.clone();
3735        let compose_child = |builder: super::StandardWebDecisionRuntimeBuilder| {
3736            builder.build_with_shared_authority(child_authority.clone())
3737        };
3738        #[cfg(feature = "reporting")]
3739        let run_started_at = SystemTime::now();
3740        let timing = self.authority.start();
3741        let started_at = timing.started_at();
3742        let mut reasons = self.initial_reasons.clone();
3743        let mut forms = Vec::new();
3744        let mut subject_reports = Vec::new();
3745        let mut known_subjects = BTreeMap::from([(self.root.url.to_string(), self.root.clone())]);
3746        let mut current_layer = vec![self.root.clone()];
3747        let mut stop = false;
3748        let mut progress_executed_subjects = 0_usize;
3749        let mut progress_processed_subjects = 0_usize;
3750        self.publish_progress(
3751            WebAssessmentProgressCheckpoint::AssessmentStarted,
3752            progress_executed_subjects,
3753            progress_processed_subjects,
3754        );
3755
3756        // The explicitly supplied session is a bounded child of this same
3757        // assessment authority. Run it before anonymous BFS so credentials,
3758        // connection state, and response bodies cannot enter ordinary subject
3759        // observation or reuse paths. A partial child produces a durable audit
3760        // while the independent anonymous assessment may still complete.
3761        #[cfg(feature = "supplied-session-review")]
3762        let defer_supplied_session = {
3763            let defer_for_wordpress = {
3764                #[cfg(feature = "wordpress-review")]
3765                {
3766                    self.wordpress_supplied_session
3767                }
3768                #[cfg(not(feature = "wordpress-review"))]
3769                {
3770                    false
3771                }
3772            };
3773            #[cfg(feature = "websocket-review")]
3774            {
3775                defer_for_wordpress || self.websocket_supplied_session
3776            }
3777            #[cfg(not(feature = "websocket-review"))]
3778            {
3779                defer_for_wordpress
3780            }
3781        };
3782        #[cfg(feature = "supplied-session-review")]
3783        if !defer_supplied_session {
3784            if let Some(session) = self.supplied_session_review.take() {
3785                self.supplied_session_audit =
3786                    Some(boxed_session_review(session, &self.authority, None).await);
3787            }
3788        }
3789        #[cfg(all(feature = "websocket-review", feature = "supplied-session-review"))]
3790        if self.websocket_supplied_session {
3791            let session = self
3792                .supplied_session_review
3793                .take()
3794                .expect("combined supplied-session inputs were validated during build");
3795            let websocket = self
3796                .websocket_review
3797                .take()
3798                .expect("combined WebSocket inputs were validated during build");
3799            let (session_audit, websocket_audit) =
3800                boxed_session_websocket(session, &self.authority, &websocket).await;
3801            self.supplied_session_audit = Some(session_audit);
3802            self.websocket_review_audit = Some(websocket_audit);
3803        }
3804
3805        // The explicit target-acceptance review consumes the S06 local proof
3806        // in a separate six-leg, exact-resource child before anonymous BFS.
3807        // It shares parent accounting/cancellation/evidence authority but no
3808        // connection pool, cookie state, response body, or token value with
3809        // the ordinary assessment.
3810        #[cfg(feature = "jwt-target-acceptance-review")]
3811        if let Some(review) = self.jwt_target_acceptance_review.take() {
3812            match boxed_optional_child(|| review.execute(&self.authority)).await {
3813                Ok(audit) => self.jwt_target_acceptance_audit = Some(audit),
3814                Err(failure) => {
3815                    self.jwt_target_acceptance_audit = failure.into_partial_audit();
3816                    return Err(WebAssessmentRuntimeError::JwtTargetAcceptanceExecution {
3817                        receipt: self.failure_receipt(
3818                            &known_subjects,
3819                            subject_reports,
3820                            forms,
3821                            WebAssessmentSubjectReport::failed(
3822                                self.root.clone(),
3823                                StandardWebDecisionAssessmentFailureParts::default(),
3824                                false,
3825                            ),
3826                            failed_reasons(&reasons),
3827                            started_at,
3828                        ),
3829                    });
3830                },
3831            }
3832        }
3833
3834        // The selected XML review is one closed three-document child of this
3835        // assessment. It uses the same parent target/provider accounting but
3836        // keeps callback authority, XML bytes, and the administrator token out
3837        // of ordinary discovery and evidence projection.
3838        #[cfg(feature = "xml-external-entity-review")]
3839        if let Some(review) = self.xml_external_entity_review.take() {
3840            let subject = EntityId::new(format!("endpoint:{}", self.root.url()))
3841                .map_err(|_| WebAssessmentRuntimeError::NativeReviewComposition)?;
3842            let selected_application = self.root.url().clone();
3843            match boxed_optional_child(|| {
3844                review.execute(&self.authority, subject, &selected_application)
3845            })
3846            .await
3847            {
3848                Ok(XmlExternalEntityRuntimeResult::Complete { audit, committed }) => {
3849                    self.xml_external_entity_review_audit = Some(audit);
3850                    self.committed_xml_external_entity_review = committed;
3851                },
3852                Ok(XmlExternalEntityRuntimeResult::Stopped { audit }) => {
3853                    let outcome = audit.outcome();
3854                    self.xml_external_entity_review_audit = Some(audit);
3855                    if outcome == XmlExternalEntityReviewOutcome::Cancelled {
3856                        reasons.insert(WebAssessmentIncompleteReason::HostCancellation);
3857                    }
3858                    reasons
3859                        .insert(WebAssessmentIncompleteReason::XmlExternalEntityReviewIncomplete);
3860                },
3861                Err(_) => {
3862                    return Err(WebAssessmentRuntimeError::XmlExternalEntityExecution {
3863                        receipt: self.failure_receipt(
3864                            &known_subjects,
3865                            subject_reports,
3866                            forms,
3867                            WebAssessmentSubjectReport::failed(
3868                                self.root.clone(),
3869                                StandardWebDecisionAssessmentFailureParts::default(),
3870                                false,
3871                            ),
3872                            failed_reasons(&reasons),
3873                            started_at,
3874                        ),
3875                    });
3876                },
3877            }
3878        }
3879
3880        while !current_layer.is_empty() && !stop {
3881            current_layer.sort_by(|left, right| left.url.as_str().cmp(right.url.as_str()));
3882            let mut next_candidates = BTreeMap::<String, PendingRoute>::new();
3883            for queued_subject in current_layer.drain(..) {
3884                let subject = known_subjects
3885                    .get(queued_subject.url.as_str())
3886                    .cloned()
3887                    .unwrap_or(queued_subject);
3888                if self.authority.cancellation().is_cancelled() {
3889                    reasons.insert(WebAssessmentIncompleteReason::HostCancellation);
3890                    stop = true;
3891                    break;
3892                }
3893                if started_at.elapsed() >= self.limits.max_wall_time() {
3894                    reasons.insert(WebAssessmentIncompleteReason::WallTimeLimit);
3895                    stop = true;
3896                    break;
3897                }
3898
3899                let mut envelope = self.ledger.snapshot(self.limits, subject.depth);
3900                let Some(current_envelope_subject) =
3901                    envelope.subjects.get_mut(subject.url.as_str())
3902                else {
3903                    return Err(WebAssessmentRuntimeError::ProjectionInvariant {
3904                        receipt: self.failure_receipt(
3905                            &known_subjects,
3906                            subject_reports,
3907                            forms,
3908                            WebAssessmentSubjectReport::failed(
3909                                subject,
3910                                StandardWebDecisionAssessmentFailureParts::default(),
3911                                false,
3912                            ),
3913                            failed_reasons(&reasons),
3914                            started_at,
3915                        ),
3916                    });
3917                };
3918                current_envelope_subject.executed = true;
3919                if subject.origin == WebAssessmentSubjectOrigin::Discovered
3920                    && subject.method == WebAssessmentMethod::Get
3921                    && self.non_root_structural_review.is_none()
3922                    && self.native_review.as_ref().is_some_and(|review| {
3923                        review.sql_query_parameter.is_none()
3924                            && review.ssti_query_parameter.is_none()
3925                    })
3926                {
3927                    if let Some(parameter) =
3928                        select_sql_review_query_parameter(&subject.query_parameter_names)
3929                    {
3930                        let seeds = NativeWebReviewSeeds::from_authorized_origin(&subject.url)?;
3931                        let observer = AssessmentReviewObserverSet::new_with_sql(
3932                            subject.url.clone(),
3933                            seeds.clone(),
3934                            None,
3935                            Some(&parameter),
3936                            Some(&parameter),
3937                            Some(&parameter),
3938                        )
3939                        .map_err(|_| WebAssessmentRuntimeError::NativeReviewComposition)?;
3940                        #[cfg(feature = "secret-exposure-review")]
3941                        let observer = if self.secret_exposure_collector.is_some() {
3942                            observer.with_secret_exposure_privacy_guard()
3943                        } else {
3944                            observer
3945                        };
3946                        let observer = Arc::new(observer);
3947                        let ledger = CommittedAssessmentReviewLedger::new_with_sql(
3948                            subject.url.clone(),
3949                            seeds.clone(),
3950                            None,
3951                            Some(&parameter),
3952                            Some(&parameter),
3953                            Some(&parameter),
3954                        )
3955                        .map_err(|_| WebAssessmentRuntimeError::NativeReviewComposition)?;
3956                        self.non_root_structural_review = Some(AssessmentNativeReviewRuntime {
3957                            target: subject.url.clone(),
3958                            seeds,
3959                            enabled_actions: enabled_native_web_review_actions(
3960                                false, false, true, true, true, None,
3961                            ),
3962                            redirect_query_parameter: None,
3963                            reflection_query_parameter: Some(parameter.clone()),
3964                            sql_query_parameter: Some(parameter),
3965                            ssti_query_parameter: select_ssti_review_query_parameter(
3966                                &subject.query_parameter_names,
3967                            ),
3968                            #[cfg(feature = "template-evaluation-review")]
3969                            template_evaluation_query_parameter: None,
3970                            observer,
3971                            ledger,
3972                        });
3973                    }
3974                }
3975                let subject_observer = AssessmentDiscoveryObserver::new(
3976                    self.discovery_policy.clone(),
3977                    self.limits,
3978                    envelope.clone(),
3979                    &subject,
3980                    self.authority.cancellation_token(),
3981                    timing.deadline(),
3982                );
3983                #[cfg(feature = "wordpress-review")]
3984                let subject_observer = if subject.url == self.root.url && subject.depth == 0 {
3985                    if let Some(review) = self.wordpress_review.as_ref() {
3986                        subject_observer.with_wordpress_signals(
3987                            review.collector(),
3988                            review.discovery_enabled(),
3989                            review.discovery_context(),
3990                            review.page_scope().is_some(),
3991                            review.supplied_session_selected(),
3992                            review.asset_fingerprint_collector(),
3993                        )
3994                    } else {
3995                        subject_observer
3996                    }
3997                } else if subject.method == WebAssessmentMethod::Get {
3998                    if let Some(review) = self.wordpress_review.as_ref() {
3999                        let subject_observer = if let Some((candidate, frozen_layout)) =
4000                            review.collector().selected_page(&subject.url)
4001                        {
4002                            subject_observer.with_wordpress_page_observation(
4003                                review.collector(),
4004                                candidate,
4005                                frozen_layout,
4006                                review.asset_fingerprint_collector(),
4007                            )
4008                        } else {
4009                            subject_observer
4010                        };
4011                        if review
4012                            .asset_fingerprint_collector()
4013                            .is_some_and(|collector| {
4014                                collector.selected_observed_asset(&subject.url).is_some()
4015                            })
4016                        {
4017                            subject_observer.with_wordpress_asset_observation(
4018                                review
4019                                    .asset_fingerprint_collector()
4020                                    .expect("selected fingerprint collector remains present"),
4021                            )
4022                        } else {
4023                            subject_observer
4024                        }
4025                    } else {
4026                        subject_observer
4027                    }
4028                } else {
4029                    subject_observer
4030                };
4031                #[cfg(feature = "secret-exposure-review")]
4032                let subject_observer =
4033                    if let Some(collector) = self.secret_exposure_collector.as_ref() {
4034                        subject_observer.with_secret_exposure(collector.clone())
4035                    } else {
4036                        subject_observer
4037                    };
4038                let subject_observer: Arc<dyn CompleteHttpResponseObserver> =
4039                    Arc::new(subject_observer);
4040                let builder = StandardWebDecisionRuntime::builder(subject.url.clone())
4041                    .with_assessment_response_observer(
4042                        subject.method.probe_method(),
4043                        subject_observer,
4044                    )
4045                    .with_assessment_defense_enforcement(self.defense_enforcement);
4046                let builder = if subject.origin == WebAssessmentSubjectOrigin::AuthorizedRoot {
4047                    match self.native_review.as_ref() {
4048                        Some(review) => {
4049                            let observer: Arc<dyn CompleteHttpResponseObserver> =
4050                                review.observer.clone();
4051                            let builder = builder.with_native_web_review(
4052                                review.seeds.clone(),
4053                                observer,
4054                                review.redirect_query_parameter.clone(),
4055                                review.reflection_query_parameter.clone(),
4056                                review.sql_query_parameter.clone(),
4057                                review.ssti_query_parameter.clone(),
4058                            );
4059                            #[cfg(feature = "template-evaluation-review")]
4060                            let builder = match review.template_evaluation_query_parameter.clone() {
4061                                Some(parameter) => {
4062                                    builder.with_native_template_evaluation_parameter(parameter)
4063                                },
4064                                None => builder,
4065                            };
4066                            builder
4067                        },
4068                        None => builder,
4069                    }
4070                } else if let Some(review) = self
4071                    .non_root_structural_review
4072                    .as_ref()
4073                    .filter(|review| review.target == subject.url)
4074                {
4075                    let observer: Arc<dyn CompleteHttpResponseObserver> = review.observer.clone();
4076                    builder.with_native_structural_review(
4077                        review.seeds.clone(),
4078                        observer,
4079                        review.sql_query_parameter.clone(),
4080                        review.ssti_query_parameter.clone(),
4081                        review.reflection_query_parameter.clone(),
4082                    )
4083                } else {
4084                    builder
4085                };
4086                #[cfg(feature = "authorization-review")]
4087                let builder = if subject.origin == WebAssessmentSubjectOrigin::AuthorizedRoot {
4088                    match self.resource_authorization_review.take() {
4089                        Some(config) => builder.with_resource_authorization_review(config),
4090                        None => builder,
4091                    }
4092                } else {
4093                    builder
4094                };
4095                #[cfg(feature = "openapi-review")]
4096                let builder = if subject.origin == WebAssessmentSubjectOrigin::AuthorizedRoot {
4097                    match self.openapi_review.take() {
4098                        Some(config) => builder.with_openapi_review(config),
4099                        None => builder,
4100                    }
4101                } else {
4102                    builder
4103                };
4104                #[cfg(feature = "rest-review")]
4105                let builder = if subject.origin == WebAssessmentSubjectOrigin::AuthorizedRoot
4106                    && self.rest_review
4107                {
4108                    builder.with_rest_review()
4109                } else {
4110                    builder
4111                };
4112                #[cfg(feature = "ssrf-oast-review")]
4113                let builder = if subject.origin == WebAssessmentSubjectOrigin::AuthorizedRoot {
4114                    match self.ssrf_oast_review.take() {
4115                        Some(config) => builder.with_ssrf_oast_review(config),
4116                        None => builder,
4117                    }
4118                } else {
4119                    builder
4120                };
4121                let mut runtime = match compose_child(builder) {
4122                    Ok(runtime) => runtime,
4123                    Err(source) => {
4124                        return Err(self.run_failed(
4125                            &mut known_subjects,
4126                            subject_reports,
4127                            forms,
4128                            FailedSubjectBoundary {
4129                                subject,
4130                                envelope,
4131                                source,
4132                                started: false,
4133                                defense: None,
4134                                planner: None,
4135                            },
4136                            &reasons,
4137                            started_at,
4138                        ));
4139                    },
4140                };
4141                if self.ledger.mark_executed(&subject).is_err() {
4142                    return Err(WebAssessmentRuntimeError::ProjectionInvariant {
4143                        receipt: self.failure_receipt(
4144                            &known_subjects,
4145                            subject_reports,
4146                            forms,
4147                            WebAssessmentSubjectReport::failed(
4148                                subject,
4149                                StandardWebDecisionAssessmentFailureParts::default(),
4150                                false,
4151                            ),
4152                            failed_reasons(&reasons),
4153                            started_at,
4154                        ),
4155                    });
4156                }
4157                progress_executed_subjects = progress_executed_subjects.saturating_add(1);
4158                self.publish_progress(
4159                    WebAssessmentProgressCheckpoint::SubjectExecutionStarted,
4160                    progress_executed_subjects,
4161                    progress_processed_subjects,
4162                );
4163                let standard_result = boxed_child_analysis(&mut runtime).await;
4164                #[cfg(feature = "authorization-review")]
4165                let resource_authorization_binding = runtime.take_resource_authorization_review();
4166                #[cfg(feature = "openapi-review")]
4167                let openapi_binding = runtime.take_openapi_review();
4168                #[cfg(feature = "rest-review")]
4169                let rest_binding = runtime.take_rest_review();
4170                #[cfg(feature = "ssrf-oast-review")]
4171                let ssrf_oast_binding = runtime.take_ssrf_oast_review();
4172                let defense = runtime.assessment_defense_controller().cloned();
4173                let planner = runtime.assessment_planner().clone();
4174                let standard = match standard_result {
4175                    Ok(report) => report,
4176                    Err(source) => {
4177                        return Err(self.run_failed(
4178                            &mut known_subjects,
4179                            subject_reports,
4180                            forms,
4181                            FailedSubjectBoundary {
4182                                subject,
4183                                envelope,
4184                                source,
4185                                started: true,
4186                                defense,
4187                                planner: Some(planner),
4188                            },
4189                            &reasons,
4190                            started_at,
4191                        ));
4192                    },
4193                };
4194                let replay = replay_standard_defense(
4195                    &standard,
4196                    &planner,
4197                    self.authority.knowledge(),
4198                    self.defense_enforcement,
4199                );
4200                let defense_valid = match (defense.as_ref(), replay) {
4201                    (Some(expected), Ok(replayed)) if replayed.exact_audit_eq(expected) => {
4202                        #[cfg(feature = "graphql-review")]
4203                        if subject.origin == WebAssessmentSubjectOrigin::AuthorizedRoot {
4204                            self.optional_child_parent_defense = Some(replayed.clone());
4205                        }
4206                        self.defense_audit.append_controller(&replayed).is_ok()
4207                    },
4208                    _ => false,
4209                };
4210                if !defense_valid {
4211                    let parts = standard.into_assessment_parts();
4212                    return Err(WebAssessmentRuntimeError::ProjectionInvariant {
4213                        receipt: self.failure_receipt(
4214                            &known_subjects,
4215                            subject_reports,
4216                            forms,
4217                            WebAssessmentSubjectReport::complete(subject, parts),
4218                            failed_reasons(&reasons),
4219                            started_at,
4220                        ),
4221                    });
4222                }
4223                #[cfg(feature = "authorization-review")]
4224                let mut authorization_hard_stop = false;
4225                #[cfg(feature = "authorization-review")]
4226                if let Some(binding) = resource_authorization_binding {
4227                    #[cfg(feature = "secret-exposure-review")]
4228                    let expected_value_free = self.secret_exposure_collector.is_some();
4229                    #[cfg(not(feature = "secret-exposure-review"))]
4230                    let expected_value_free = false;
4231                    let forced_outcome = if self.authority.cancellation().is_cancelled() {
4232                        Some(AuthorizationReviewOutcome::Cancelled)
4233                    } else if standard.limit_exceeded().is_some() {
4234                        Some(AuthorizationReviewOutcome::BudgetExhausted)
4235                    } else {
4236                        None
4237                    };
4238                    let forced_runtime_limit = standard.limit_exceeded().cloned();
4239                    match binding.finalize(
4240                        self.authority.knowledge(),
4241                        &standard,
4242                        expected_value_free,
4243                        forced_outcome,
4244                        forced_runtime_limit,
4245                    ) {
4246                        Ok(ResourceAuthorizationRuntimeResult::Complete(committed)) => {
4247                            let committed = *committed;
4248                            self.authorization_review_audit = Some(committed.audit().clone());
4249                            self.committed_resource_authorization_review = Some(committed);
4250                        },
4251                        Ok(ResourceAuthorizationRuntimeResult::Stopped {
4252                            audit,
4253                            runtime_limit,
4254                        }) => {
4255                            let outcome = audit.outcome();
4256                            self.authorization_review_audit = Some(audit);
4257                            authorization_hard_stop = true;
4258                            if let Some(limit) = runtime_limit {
4259                                reasons.insert(reason_for_runtime_dimension(limit.dimension()));
4260                            }
4261                            if outcome == AuthorizationReviewOutcome::Cancelled {
4262                                reasons.insert(WebAssessmentIncompleteReason::HostCancellation);
4263                            }
4264                            reasons.insert(
4265                                WebAssessmentIncompleteReason::AuthorizationReviewIncomplete,
4266                            );
4267                        },
4268                        Err(_) => {
4269                            let parts = standard.into_assessment_parts();
4270                            return Err(WebAssessmentRuntimeError::ProjectionInvariant {
4271                                receipt: self.failure_receipt(
4272                                    &known_subjects,
4273                                    subject_reports,
4274                                    forms,
4275                                    WebAssessmentSubjectReport::complete(subject, parts),
4276                                    failed_reasons(&reasons),
4277                                    started_at,
4278                                ),
4279                            });
4280                        },
4281                    }
4282                }
4283                #[cfg(feature = "openapi-review")]
4284                if let Some(binding) = openapi_binding {
4285                    let forced_outcome = if self.authority.cancellation().is_cancelled() {
4286                        Some(OpenApiRuntimeOutcome::Cancelled)
4287                    } else if standard.limit_exceeded().is_some() {
4288                        Some(OpenApiRuntimeOutcome::BudgetExhausted)
4289                    } else {
4290                        None
4291                    };
4292                    let forced_runtime_limit = standard.limit_exceeded().cloned();
4293                    match binding.finalize(
4294                        self.authority.knowledge(),
4295                        &standard,
4296                        forced_outcome,
4297                        forced_runtime_limit,
4298                    ) {
4299                        Ok(OpenApiRuntimeResult::Complete(committed)) => {
4300                            self.openapi_review_audit = Some(committed.audit().clone());
4301                            self.committed_openapi_review = Some(committed);
4302                        },
4303                        Ok(OpenApiRuntimeResult::Stopped {
4304                            audit,
4305                            runtime_limit,
4306                        }) => {
4307                            let outcome = audit.outcome();
4308                            self.openapi_review_audit = Some(audit);
4309                            if let Some(limit) = runtime_limit {
4310                                reasons.insert(reason_for_runtime_dimension(limit.dimension()));
4311                            }
4312                            if outcome == OpenApiRuntimeOutcome::Cancelled {
4313                                reasons.insert(WebAssessmentIncompleteReason::HostCancellation);
4314                            }
4315                            reasons.insert(WebAssessmentIncompleteReason::OpenApiReviewIncomplete);
4316                        },
4317                        Err(_) => {
4318                            let parts = standard.into_assessment_parts();
4319                            return Err(WebAssessmentRuntimeError::ProjectionInvariant {
4320                                receipt: self.failure_receipt(
4321                                    &known_subjects,
4322                                    subject_reports,
4323                                    forms,
4324                                    WebAssessmentSubjectReport::complete(subject, parts),
4325                                    failed_reasons(&reasons),
4326                                    started_at,
4327                                ),
4328                            });
4329                        },
4330                    }
4331                }
4332                #[cfg(feature = "rest-review")]
4333                if let Some(binding) = rest_binding {
4334                    let forced_outcome = if self.authority.cancellation().is_cancelled() {
4335                        Some(RestRuntimeOutcome::Cancelled)
4336                    } else if standard.limit_exceeded().is_some() {
4337                        Some(RestRuntimeOutcome::BudgetExhausted)
4338                    } else {
4339                        None
4340                    };
4341                    let forced_runtime_limit = standard.limit_exceeded().cloned();
4342                    match binding.finalize(
4343                        self.authority.knowledge(),
4344                        &standard,
4345                        forced_outcome,
4346                        forced_runtime_limit,
4347                    ) {
4348                        Ok(RestRuntimeResult::Complete(committed)) => {
4349                            self.rest_review_audit = Some(committed.audit().clone());
4350                            self.committed_rest_review = Some(committed);
4351                        },
4352                        Ok(RestRuntimeResult::Stopped {
4353                            audit,
4354                            runtime_limit,
4355                        }) => {
4356                            let outcome = audit.outcome();
4357                            self.rest_review_audit = Some(audit);
4358                            if let Some(limit) = runtime_limit {
4359                                reasons.insert(reason_for_runtime_dimension(limit.dimension()));
4360                            }
4361                            if outcome == RestRuntimeOutcome::Cancelled {
4362                                reasons.insert(WebAssessmentIncompleteReason::HostCancellation);
4363                            }
4364                            reasons.insert(WebAssessmentIncompleteReason::RestReviewIncomplete);
4365                        },
4366                        Err(_) => {
4367                            let parts = standard.into_assessment_parts();
4368                            return Err(WebAssessmentRuntimeError::ProjectionInvariant {
4369                                receipt: self.failure_receipt(
4370                                    &known_subjects,
4371                                    subject_reports,
4372                                    forms,
4373                                    WebAssessmentSubjectReport::complete(subject, parts),
4374                                    failed_reasons(&reasons),
4375                                    started_at,
4376                                ),
4377                            });
4378                        },
4379                    }
4380                }
4381                #[cfg(feature = "ssrf-oast-review")]
4382                if let Some(binding) = ssrf_oast_binding {
4383                    let forced_terminal = if self.authority.cancellation().is_cancelled() {
4384                        Some(SsrfOastTerminalState::Cancelled)
4385                    } else if standard.limit_exceeded().is_some() {
4386                        Some(SsrfOastTerminalState::BudgetExhausted)
4387                    } else {
4388                        None
4389                    };
4390                    match binding.finalize(
4391                        self.authority.knowledge(),
4392                        standard.transport(),
4393                        forced_terminal,
4394                        standard.limit_exceeded().cloned(),
4395                    ) {
4396                        Ok(SsrfOastRuntimeResult::Complete(committed)) => {
4397                            self.ssrf_oast_review_audit = Some(committed.audit().clone());
4398                            self.committed_ssrf_oast_review = Some(committed);
4399                        },
4400                        Ok(SsrfOastRuntimeResult::Stopped {
4401                            audit,
4402                            runtime_limit,
4403                        }) => {
4404                            let cancelled = audit.outcome()
4405                                == super::ssrf_oast_runtime::SsrfOastRuntimeOutcome::Cancelled;
4406                            self.ssrf_oast_review_audit = Some(audit);
4407                            if let Some(limit) = runtime_limit {
4408                                reasons.insert(reason_for_runtime_dimension(limit.dimension()));
4409                            }
4410                            if cancelled {
4411                                reasons.insert(WebAssessmentIncompleteReason::HostCancellation);
4412                            }
4413                            reasons.insert(WebAssessmentIncompleteReason::SsrfOastReviewIncomplete);
4414                        },
4415                        Err(_) => {
4416                            let parts = standard.into_assessment_parts();
4417                            return Err(WebAssessmentRuntimeError::ProjectionInvariant {
4418                                receipt: self.failure_receipt(
4419                                    &known_subjects,
4420                                    subject_reports,
4421                                    forms,
4422                                    WebAssessmentSubjectReport::complete(subject, parts),
4423                                    failed_reasons(&reasons),
4424                                    started_at,
4425                                ),
4426                            });
4427                        },
4428                    }
4429                }
4430                let selected_review =
4431                    if subject.origin == WebAssessmentSubjectOrigin::AuthorizedRoot {
4432                        self.native_review.as_mut()
4433                    } else {
4434                        self.non_root_structural_review
4435                            .as_mut()
4436                            .filter(|review| review.target == subject.url)
4437                    };
4438                #[cfg(feature = "authorization-review")]
4439                let allow_structural_followup = !authorization_hard_stop;
4440                #[cfg(not(feature = "authorization-review"))]
4441                let allow_structural_followup = true;
4442                let mut pending_xss_review = None;
4443                #[cfg(feature = "normalization-resilience")]
4444                let mut pending_normalization_review = None;
4445                if let Some(review) = selected_review {
4446                    let native_review_complete =
4447                        replay_native_review(&standard, review, self.authority.knowledge());
4448                    match native_review_complete {
4449                        Ok(true) => {
4450                            if allow_structural_followup && self.xss_structural_review.is_none() {
4451                                pending_xss_review = review
4452                                    .ledger
4453                                    .xss_selection_inputs()
4454                                    .into_iter()
4455                                    .find_map(|input| {
4456                                        select_xss_probe_families(
4457                                            input.context,
4458                                            &input.attribute_source,
4459                                            &input.javascript_source,
4460                                        )
4461                                        .into_iter()
4462                                        .next()
4463                                        .map(|selection| {
4464                                            (
4465                                                review.target.clone(),
4466                                                review.seeds.clone(),
4467                                                input.query_parameter,
4468                                                input.source_evidence_ids,
4469                                                selection,
4470                                            )
4471                                        })
4472                                    });
4473                            }
4474                        },
4475                        Ok(false) => {
4476                            reasons.insert(
4477                                WebAssessmentIncompleteReason::DifferentialReviewIncomplete,
4478                            );
4479                        },
4480                        Err(()) => {
4481                            let parts = standard.into_assessment_parts();
4482                            return Err(WebAssessmentRuntimeError::ProjectionInvariant {
4483                                receipt: self.failure_receipt(
4484                                    &known_subjects,
4485                                    subject_reports,
4486                                    forms,
4487                                    WebAssessmentSubjectReport::complete(subject, parts),
4488                                    failed_reasons(&reasons),
4489                                    started_at,
4490                                ),
4491                            });
4492                        },
4493                    }
4494                }
4495                if let Some((target, seeds, parameter, source_evidence_ids, selection)) =
4496                    pending_xss_review
4497                {
4498                    let xss_action = selection.action_kind();
4499                    let observer = AssessmentReviewObserverSet::new_xss_with_source_evidence(
4500                        target.clone(),
4501                        seeds.clone(),
4502                        &parameter,
4503                        selection.clone(),
4504                        source_evidence_ids.clone(),
4505                    )
4506                    .map_err(|_| WebAssessmentRuntimeError::NativeReviewComposition)?;
4507                    #[cfg(feature = "secret-exposure-review")]
4508                    let observer = if self.secret_exposure_collector.is_some() {
4509                        observer.with_secret_exposure_privacy_guard()
4510                    } else {
4511                        observer
4512                    };
4513                    let observer = Arc::new(observer);
4514                    let ledger = CommittedAssessmentReviewLedger::new_xss_with_source_evidence(
4515                        target.clone(),
4516                        seeds.clone(),
4517                        &parameter,
4518                        selection.clone(),
4519                        source_evidence_ids,
4520                    )
4521                    .map_err(|_| WebAssessmentRuntimeError::NativeReviewComposition)?;
4522                    let mut review = AssessmentNativeReviewRuntime {
4523                        target: target.clone(),
4524                        seeds: seeds.clone(),
4525                        redirect_query_parameter: None,
4526                        reflection_query_parameter: None,
4527                        sql_query_parameter: None,
4528                        ssti_query_parameter: None,
4529                        #[cfg(feature = "template-evaluation-review")]
4530                        template_evaluation_query_parameter: None,
4531                        observer: observer.clone(),
4532                        ledger,
4533                        enabled_actions: vec![xss_action],
4534                    };
4535                    let response_observer: Arc<dyn CompleteHttpResponseObserver> = observer;
4536                    let xss_builder = StandardWebDecisionRuntime::builder(target)
4537                        .with_native_xss_structural_review(
4538                            seeds,
4539                            response_observer,
4540                            parameter,
4541                            selection,
4542                        )
4543                        .with_assessment_defense_enforcement(self.defense_enforcement);
4544                    let mut xss_runtime = compose_child(xss_builder)?;
4545                    let xss_result = boxed_child_analysis(&mut xss_runtime).await;
4546                    let xss_defense = xss_runtime.assessment_defense_controller().cloned();
4547                    let xss_planner = xss_runtime.assessment_planner().clone();
4548                    match xss_result {
4549                        Ok(xss_report) => {
4550                            let replay = replay_standard_defense(
4551                                &xss_report,
4552                                &xss_planner,
4553                                self.authority.knowledge(),
4554                                self.defense_enforcement,
4555                            );
4556                            let replayed_defense = match (xss_defense.as_ref(), replay) {
4557                                (Some(expected), Ok(replayed))
4558                                    if replayed.exact_audit_eq(expected) =>
4559                                {
4560                                    replayed
4561                                },
4562                                _ => {
4563                                    return Err(WebAssessmentRuntimeError::NativeReviewComposition);
4564                                },
4565                            };
4566                            if self
4567                                .defense_audit
4568                                .append_controller(&replayed_defense)
4569                                .is_err()
4570                            {
4571                                return Err(WebAssessmentRuntimeError::NativeReviewComposition);
4572                            }
4573                            match replay_native_review(
4574                                &xss_report,
4575                                &mut review,
4576                                self.authority.knowledge(),
4577                            ) {
4578                                Ok(true) if !review.ledger.has_incomplete_xss_observation() =>
4579                                {
4580                                    #[cfg(feature = "normalization-resilience")]
4581                                    if self.normalization_resilience
4582                                        && self.normalization_review.is_none()
4583                                    {
4584                                        let parent = review
4585                                            .ledger
4586                                            .normalization_parent_evidence(
4587                                                replayed_defense.ledger(),
4588                                            )
4589                                            .map_err(|_| {
4590                                                WebAssessmentRuntimeError::NativeReviewComposition
4591                                            })?;
4592                                        if let Some(parent) = parent {
4593                                            if let Some(selection) = select_normalization_transform(
4594                                                parent.selection(),
4595                                                None,
4596                                            ) {
4597                                                pending_normalization_review = Some((
4598                                                    review.target.clone(),
4599                                                    review.seeds.clone(),
4600                                                    parent,
4601                                                    selection,
4602                                                ));
4603                                            }
4604                                        }
4605                                    }
4606                                },
4607                                Ok(_) => {
4608                                    reasons.insert(
4609                                        WebAssessmentIncompleteReason::DifferentialReviewIncomplete,
4610                                    );
4611                                },
4612                                Err(()) => {
4613                                    return Err(WebAssessmentRuntimeError::NativeReviewComposition);
4614                                },
4615                            }
4616                        },
4617                        Err(_) => {
4618                            reasons.insert(
4619                                WebAssessmentIncompleteReason::DifferentialReviewIncomplete,
4620                            );
4621                        },
4622                    }
4623                    self.xss_structural_review = Some(review);
4624                }
4625                #[cfg(feature = "normalization-resilience")]
4626                if let Some((target, seeds, parent, selection)) = pending_normalization_review {
4627                    let observer = AssessmentReviewObserverSet::new_normalization(
4628                        target.clone(),
4629                        seeds.clone(),
4630                        selection.clone(),
4631                        &parent,
4632                    )
4633                    .map_err(|_| WebAssessmentRuntimeError::NativeReviewComposition)?;
4634                    #[cfg(feature = "secret-exposure-review")]
4635                    let observer = if self.secret_exposure_collector.is_some() {
4636                        observer.with_secret_exposure_privacy_guard()
4637                    } else {
4638                        observer
4639                    };
4640                    let observer = Arc::new(observer);
4641                    let ledger = CommittedAssessmentReviewLedger::new_normalization(
4642                        target.clone(),
4643                        seeds.clone(),
4644                        selection.clone(),
4645                        &parent,
4646                    )
4647                    .map_err(|_| WebAssessmentRuntimeError::NativeReviewComposition)?;
4648                    let mut review = AssessmentNativeReviewRuntime {
4649                        target: target.clone(),
4650                        seeds: seeds.clone(),
4651                        redirect_query_parameter: None,
4652                        reflection_query_parameter: None,
4653                        sql_query_parameter: None,
4654                        ssti_query_parameter: None,
4655                        #[cfg(feature = "template-evaluation-review")]
4656                        template_evaluation_query_parameter: None,
4657                        observer: observer.clone(),
4658                        ledger,
4659                        enabled_actions: vec![
4660                            NativeWebReviewActionKind::NormalizationResilienceQueryPair,
4661                        ],
4662                    };
4663                    let response_observer: Arc<dyn CompleteHttpResponseObserver> = observer;
4664                    let normalization_builder = StandardWebDecisionRuntime::builder(target)
4665                        .with_native_normalization_resilience_review(
4666                            seeds,
4667                            response_observer,
4668                            parent.query_parameter().to_owned(),
4669                            selection,
4670                        )
4671                        .with_assessment_defense_enforcement(self.defense_enforcement);
4672                    let mut normalization_runtime = compose_child(normalization_builder)?;
4673                    let normalization_result =
4674                        boxed_child_analysis(&mut normalization_runtime).await;
4675                    let normalization_defense = normalization_runtime
4676                        .assessment_defense_controller()
4677                        .cloned();
4678                    let normalization_planner = normalization_runtime.assessment_planner().clone();
4679                    match normalization_result {
4680                        Ok(normalization_report) => {
4681                            let replayed_defense = match (
4682                                normalization_defense.as_ref(),
4683                                replay_standard_defense(
4684                                    &normalization_report,
4685                                    &normalization_planner,
4686                                    self.authority.knowledge(),
4687                                    self.defense_enforcement,
4688                                ),
4689                            ) {
4690                                (Some(expected), Ok(replayed))
4691                                    if replayed.exact_audit_eq(expected) =>
4692                                {
4693                                    replayed
4694                                },
4695                                _ => {
4696                                    return Err(WebAssessmentRuntimeError::NativeReviewComposition);
4697                                },
4698                            };
4699                            if self
4700                                .defense_audit
4701                                .append_controller(&replayed_defense)
4702                                .is_err()
4703                            {
4704                                return Err(WebAssessmentRuntimeError::NativeReviewComposition);
4705                            }
4706                            match replay_native_review(
4707                                &normalization_report,
4708                                &mut review,
4709                                self.authority.knowledge(),
4710                            ) {
4711                                Ok(true) => match review
4712                                    .ledger
4713                                    .finalize_normalization(replayed_defense.ledger())
4714                                    .map_err(|_| {
4715                                        WebAssessmentRuntimeError::NativeReviewComposition
4716                                    })? {
4717                                    super::assessment_review::NormalizationReviewOutcome::Incomplete => {
4718                                        reasons.insert(
4719                                            WebAssessmentIncompleteReason::DifferentialReviewIncomplete,
4720                                        );
4721                                    },
4722                                    super::assessment_review::NormalizationReviewOutcome::SemanticNormalizationGapObserved
4723                                    | super::assessment_review::NormalizationReviewOutcome::VariantStillBlocked
4724                                    | super::assessment_review::NormalizationReviewOutcome::VariantAcceptedSemanticsUnknown
4725                                    | super::assessment_review::NormalizationReviewOutcome::ReplayMismatch => {},
4726                                },
4727                                Ok(false) => {
4728                                    reasons.insert(
4729                                        WebAssessmentIncompleteReason::DifferentialReviewIncomplete,
4730                                    );
4731                                },
4732                                Err(()) => {
4733                                    return Err(
4734                                        WebAssessmentRuntimeError::NativeReviewComposition,
4735                                    );
4736                                },
4737                            }
4738                        },
4739                        Err(_) => {
4740                            reasons.insert(
4741                                WebAssessmentIncompleteReason::DifferentialReviewIncomplete,
4742                            );
4743                        },
4744                    }
4745                    self.normalization_review = Some(review);
4746                }
4747                classify_standard_completion(&standard, &mut reasons);
4748                #[cfg(feature = "authorization-review")]
4749                if authorization_hard_stop
4750                    && matches!(
4751                        standard.terminal(),
4752                        DecisionLoopCommand::Halt {
4753                            reason: DecisionStopReason::AdaptationLimit
4754                        }
4755                    )
4756                {
4757                    // The higher-priority authorization terminal rule uses a
4758                    // parent-owned Halt directive only to suppress later
4759                    // actions. Its typed audit already records the target or
4760                    // transport outcome, so the generic adaptation label would
4761                    // be synthetic. Real runtime limits remain classified.
4762                    reasons.remove(&WebAssessmentIncompleteReason::AdaptationLimit);
4763                }
4764                let cancelled_at_subject_boundary = self.authority.cancellation().is_cancelled();
4765                let wall_time_reached_at_subject_boundary =
4766                    started_at.elapsed() >= self.limits.max_wall_time();
4767                if cancelled_at_subject_boundary {
4768                    reasons.insert(WebAssessmentIncompleteReason::HostCancellation);
4769                }
4770                if wall_time_reached_at_subject_boundary {
4771                    reasons.insert(WebAssessmentIncompleteReason::WallTimeLimit);
4772                }
4773                let mut should_stop = cancelled_at_subject_boundary
4774                    || wall_time_reached_at_subject_boundary
4775                    || standard.limit_exceeded().is_some()
4776                    || matches!(
4777                        standard.terminal(),
4778                        DecisionLoopCommand::Halt {
4779                            reason: DecisionStopReason::CancelledByHost
4780                                | DecisionStopReason::RuntimeBudgetLimit
4781                        }
4782                    );
4783                #[cfg(feature = "authorization-review")]
4784                {
4785                    should_stop |= authorization_hard_stop;
4786                }
4787                let projection = projection_from_committed_bootstrap(
4788                    standard.bootstrap(),
4789                    self.authority.knowledge(),
4790                    &subject,
4791                    &self.discovery_policy,
4792                    self.limits,
4793                    &envelope,
4794                );
4795                let parts = standard.into_assessment_parts();
4796                let subject_report = WebAssessmentSubjectReport::complete(subject.clone(), parts);
4797                let mut suppress_discovery_projection = false;
4798
4799                match projection {
4800                    Ok(projection) => {
4801                        if self
4802                            .replay_passive_bootstrap(
4803                                subject_report.bootstrap(),
4804                                &subject,
4805                                &mut reasons,
4806                            )
4807                            .is_err()
4808                        {
4809                            return Err(WebAssessmentRuntimeError::ProjectionInvariant {
4810                                receipt: self.failure_receipt(
4811                                    &known_subjects,
4812                                    subject_reports,
4813                                    forms,
4814                                    subject_report,
4815                                    failed_reasons(&reasons),
4816                                    started_at,
4817                                ),
4818                            });
4819                        }
4820                        if self
4821                            .semantic_evidence
4822                            .commit_bootstrap(
4823                                subject_report.bootstrap(),
4824                                self.authority.knowledge(),
4825                                &subject,
4826                            )
4827                            .is_err()
4828                        {
4829                            return Err(WebAssessmentRuntimeError::ProjectionInvariant {
4830                                receipt: self.failure_receipt(
4831                                    &known_subjects,
4832                                    subject_reports,
4833                                    forms,
4834                                    subject_report,
4835                                    failed_reasons(&reasons),
4836                                    started_at,
4837                                ),
4838                            });
4839                        }
4840                        #[cfg(feature = "wordpress-review")]
4841                        if let Some(review) = self.wordpress_review.as_ref().filter(|review| {
4842                            review.page_scope().is_some()
4843                                || review.asset_fingerprints_enabled()
4844                                || review.supplied_session_selected()
4845                        }) {
4846                            let page_subject = EntityId::new(format!("endpoint:{}", subject.url))
4847                                .map_err(|_| {
4848                                WebAssessmentRuntimeError::NativeReviewComposition
4849                            })?;
4850                            let root_subject = EntityId::new(format!("endpoint:{}", self.root.url))
4851                                .map_err(|_| WebAssessmentRuntimeError::NativeReviewComposition)?;
4852                            let discovery_committed = if subject.origin
4853                                == WebAssessmentSubjectOrigin::AuthorizedRoot
4854                                && (review.page_scope().is_some()
4855                                    || review.supplied_session_selected())
4856                            {
4857                                review.collector().commit_staged_entry(
4858                                    self.authority.knowledge(),
4859                                    &page_subject,
4860                                    &subject.url,
4861                                )
4862                            } else if review.page_scope().is_some() {
4863                                review.collector().commit_staged_page(
4864                                    self.authority.knowledge(),
4865                                    &root_subject,
4866                                    &page_subject,
4867                                    &subject.url,
4868                                )
4869                            } else {
4870                                Ok(())
4871                            };
4872                            let fingerprints_committed = review
4873                                .asset_fingerprint_collector()
4874                                .map(|fingerprints| {
4875                                    fingerprints
4876                                        .commit_staged_candidates(
4877                                            self.authority.knowledge(),
4878                                            &root_subject,
4879                                            &page_subject,
4880                                            &subject.url,
4881                                        )
4882                                        .and_then(|()| {
4883                                            fingerprints.commit_staged_response(
4884                                                self.authority.knowledge(),
4885                                                &root_subject,
4886                                                &page_subject,
4887                                                &subject.url,
4888                                            )
4889                                        })
4890                                })
4891                                .transpose();
4892                            if discovery_committed.is_err() || fingerprints_committed.is_err() {
4893                                return Err(WebAssessmentRuntimeError::ProjectionInvariant {
4894                                    receipt: self.failure_receipt(
4895                                        &known_subjects,
4896                                        subject_reports,
4897                                        forms,
4898                                        subject_report,
4899                                        failed_reasons(&reasons),
4900                                        started_at,
4901                                    ),
4902                                });
4903                            }
4904                        }
4905                        #[cfg(all(
4906                            feature = "wordpress-review",
4907                            feature = "supplied-session-review"
4908                        ))]
4909                        if self.wordpress_supplied_session
4910                            && subject.origin == WebAssessmentSubjectOrigin::AuthorizedRoot
4911                            && !should_stop
4912                        {
4913                            let root_subject = EntityId::new(format!("endpoint:{}", self.root.url))
4914                                .map_err(|_| WebAssessmentRuntimeError::NativeReviewComposition)?;
4915                            let binding = self.wordpress_review.as_ref().and_then(|review| {
4916                                review.supplied_session_observation_binding(root_subject)
4917                            });
4918                            let session = self.supplied_session_review.take();
4919                            let (Some(binding), Some(session)) = (binding, session) else {
4920                                return Err(WebAssessmentRuntimeError::ProjectionInvariant {
4921                                    receipt: self.failure_receipt(
4922                                        &known_subjects,
4923                                        subject_reports,
4924                                        forms,
4925                                        subject_report,
4926                                        failed_reasons(&reasons),
4927                                        started_at,
4928                                    ),
4929                                });
4930                            };
4931                            let audit =
4932                                boxed_session_review(session, &self.authority, Some(&binding))
4933                                    .await;
4934                            let complete = binding.complete(&audit);
4935                            self.supplied_session_audit = Some(audit);
4936                            if complete.is_err() {
4937                                return Err(WebAssessmentRuntimeError::ProjectionInvariant {
4938                                    receipt: self.failure_receipt(
4939                                        &known_subjects,
4940                                        subject_reports,
4941                                        forms,
4942                                        subject_report,
4943                                        failed_reasons(&reasons),
4944                                        started_at,
4945                                    ),
4946                                });
4947                            }
4948                        }
4949                        if subject.origin == WebAssessmentSubjectOrigin::AuthorizedRoot
4950                            && !should_stop
4951                        {
4952                            let api_visibility_result = match self.root_api_visibility.as_mut() {
4953                                Some(runtime) => {
4954                                    Some(boxed_optional_child(|| runtime.execute()).await)
4955                                },
4956                                None => None,
4957                            };
4958                            if let Some(api_visibility_result) = api_visibility_result {
4959                                match api_visibility_result {
4960                                    Ok(RootApiVisibilityOutcome::NoDifference) => {},
4961                                    Ok(RootApiVisibilityOutcome::NeedsReview(committed)) => {
4962                                        self.committed_api_visibility = Some(*committed);
4963                                    },
4964                                    Ok(RootApiVisibilityOutcome::Incomplete) => {
4965                                        reasons.insert(
4966                                            WebAssessmentIncompleteReason::ApiVisibilityReviewIncomplete,
4967                                        );
4968                                        should_stop = true;
4969                                        suppress_discovery_projection = true;
4970                                    },
4971                                    Ok(RootApiVisibilityOutcome::Cancelled) => {
4972                                        reasons.insert(
4973                                            WebAssessmentIncompleteReason::HostCancellation,
4974                                        );
4975                                        should_stop = true;
4976                                        suppress_discovery_projection = true;
4977                                    },
4978                                    Ok(RootApiVisibilityOutcome::RuntimeLimit(dimension)) => {
4979                                        reasons.insert(reason_for_runtime_dimension(dimension));
4980                                        should_stop = true;
4981                                        suppress_discovery_projection = true;
4982                                    },
4983                                    Ok(RootApiVisibilityOutcome::ContractMismatch) => {
4984                                        return Err(
4985                                            WebAssessmentRuntimeError::ProjectionInvariant {
4986                                                receipt: self.failure_receipt(
4987                                                    &known_subjects,
4988                                                    subject_reports,
4989                                                    forms,
4990                                                    subject_report,
4991                                                    failed_reasons(&reasons),
4992                                                    started_at,
4993                                                ),
4994                                            },
4995                                        );
4996                                    },
4997                                    Err(source) => {
4998                                        return Err(
4999                                            WebAssessmentRuntimeError::ApiVisibilityRunFailed {
5000                                                receipt: self.failure_receipt(
5001                                                    &known_subjects,
5002                                                    subject_reports,
5003                                                    forms,
5004                                                    subject_report,
5005                                                    failed_reasons(&reasons),
5006                                                    started_at,
5007                                                ),
5008                                                source: Box::new(source),
5009                                            },
5010                                        );
5011                                    },
5012                                }
5013                            }
5014                        }
5015                        #[cfg(feature = "wordpress-review")]
5016                        if subject.origin == WebAssessmentSubjectOrigin::AuthorizedRoot
5017                            && !should_stop
5018                        {
5019                            let discovery_result = match self.wordpress_review.as_mut() {
5020                                Some(review)
5021                                    if review.discovery_enabled()
5022                                        && review.page_scope().is_none() =>
5023                                {
5024                                    let root_subject =
5025                                        EntityId::new(format!("endpoint:{}", self.root.url()))
5026                                            .map_err(|_| {
5027                                                WebAssessmentRuntimeError::NativeReviewComposition
5028                                            })?;
5029                                    Some(
5030                                        boxed_optional_child(|| {
5031                                            review.execute_discovery(
5032                                                root_subject,
5033                                                &self.authority,
5034                                                timing.deadline(),
5035                                            )
5036                                        })
5037                                        .await,
5038                                    )
5039                                },
5040                                _ => None,
5041                            };
5042                            match discovery_result {
5043                                None => {},
5044                                Some(Ok(WordPressDiscoveryStop::Complete)) => {},
5045                                Some(Ok(WordPressDiscoveryStop::Cancelled)) => {
5046                                    reasons.insert(WebAssessmentIncompleteReason::HostCancellation);
5047                                    reasons.insert(
5048                                        WebAssessmentIncompleteReason::WordPressReviewIncomplete,
5049                                    );
5050                                    should_stop = true;
5051                                    suppress_discovery_projection = true;
5052                                },
5053                                Some(Ok(WordPressDiscoveryStop::DeadlineExceeded)) => {
5054                                    reasons.insert(WebAssessmentIncompleteReason::WallTimeLimit);
5055                                    reasons.insert(
5056                                        WebAssessmentIncompleteReason::WordPressReviewIncomplete,
5057                                    );
5058                                    should_stop = true;
5059                                    suppress_discovery_projection = true;
5060                                },
5061                                Some(Ok(WordPressDiscoveryStop::RuntimeLimit(dimension))) => {
5062                                    reasons.insert(reason_for_runtime_dimension(dimension));
5063                                    reasons.insert(
5064                                        WebAssessmentIncompleteReason::WordPressReviewIncomplete,
5065                                    );
5066                                    should_stop = true;
5067                                    suppress_discovery_projection = true;
5068                                },
5069                                Some(Ok(WordPressDiscoveryStop::DiscoveryResponseLimit)) => {
5070                                    reasons.insert(
5071                                        WebAssessmentIncompleteReason::WordPressReviewIncomplete,
5072                                    );
5073                                    should_stop = true;
5074                                    suppress_discovery_projection = true;
5075                                },
5076                                Some(Err(
5077                                    super::wordpress_discovery::WordPressDiscoveryExecutionError::CandidateIdentityLimitExceeded,
5078                                )) => {
5079                                    reasons.insert(
5080                                        WebAssessmentIncompleteReason::WordPressReviewIncomplete,
5081                                    );
5082                                    should_stop = true;
5083                                    suppress_discovery_projection = true;
5084                                },
5085                                Some(Err(_)) => {
5086                                    return Err(WebAssessmentRuntimeError::ProjectionInvariant {
5087                                        receipt: self.failure_receipt(
5088                                            &known_subjects,
5089                                            subject_reports,
5090                                            forms,
5091                                            subject_report,
5092                                            failed_reasons(&reasons),
5093                                            started_at,
5094                                        ),
5095                                    });
5096                                },
5097                            }
5098                        }
5099                        if let Some(projection) = projection {
5100                            projection.add_reasons(&mut reasons);
5101                            if !suppress_discovery_projection {
5102                                self.ledger.apply(&projection);
5103                                forms.extend(projection.forms);
5104                                for route in projection.routes {
5105                                    let route_is_pending = self
5106                                        .ledger
5107                                        .subject_admission(&route.url)
5108                                        .is_some_and(|entry| !entry.executed);
5109                                    if route_is_pending {
5110                                        if let Some(existing) =
5111                                            known_subjects.get_mut(route.url.as_str())
5112                                        {
5113                                            merge_subject_route(
5114                                                existing,
5115                                                &route,
5116                                                subject.depth + 1,
5117                                                self.limits.max_query_parameter_names(),
5118                                            );
5119                                        } else {
5120                                            known_subjects.insert(
5121                                                route.url.to_string(),
5122                                                WebAssessmentSubject {
5123                                                    url: route.url.clone(),
5124                                                    method: route.method,
5125                                                    depth: subject.depth.saturating_add(1),
5126                                                    origin: WebAssessmentSubjectOrigin::Discovered,
5127                                                    query_parameter_names: route
5128                                                        .query_parameter_names
5129                                                        .clone(),
5130                                                    evidence_ids: route.evidence_ids.clone(),
5131                                                },
5132                                            );
5133                                        }
5134                                    }
5135                                    merge_pending_route(
5136                                        &mut next_candidates,
5137                                        route,
5138                                        subject.depth + 1,
5139                                        self.limits.max_query_parameter_names(),
5140                                    );
5141                                }
5142                            }
5143                        }
5144                    },
5145                    Err(()) => {
5146                        return Err(WebAssessmentRuntimeError::ProjectionInvariant {
5147                            receipt: self.failure_receipt(
5148                                &known_subjects,
5149                                subject_reports,
5150                                forms,
5151                                subject_report,
5152                                failed_reasons(&reasons),
5153                                started_at,
5154                            ),
5155                        });
5156                    },
5157                }
5158                subject_reports.push(subject_report);
5159                progress_processed_subjects = progress_processed_subjects.saturating_add(1);
5160                self.publish_progress(
5161                    WebAssessmentProgressCheckpoint::SubjectStateCommitted,
5162                    progress_executed_subjects,
5163                    progress_processed_subjects,
5164                );
5165                if should_stop {
5166                    stop = true;
5167                    break;
5168                }
5169            }
5170
5171            for (_, candidate) in next_candidates {
5172                if let Some(existing) = known_subjects.get_mut(candidate.url.as_str()) {
5173                    if self
5174                        .ledger
5175                        .subject_admission(&candidate.url)
5176                        .is_some_and(|entry| !entry.executed)
5177                    {
5178                        merge_subject_route(
5179                            existing,
5180                            &candidate,
5181                            candidate.depth,
5182                            self.limits.max_query_parameter_names(),
5183                        );
5184                    }
5185                } else {
5186                    known_subjects.insert(
5187                        candidate.url.to_string(),
5188                        WebAssessmentSubject {
5189                            url: candidate.url.clone(),
5190                            method: candidate.method,
5191                            depth: candidate.depth,
5192                            origin: WebAssessmentSubjectOrigin::Discovered,
5193                            query_parameter_names: candidate.query_parameter_names.clone(),
5194                            evidence_ids: candidate.evidence_ids.clone(),
5195                        },
5196                    );
5197                }
5198                if !stop
5199                    && self
5200                        .ledger
5201                        .subject_admission(&candidate.url)
5202                        .is_some_and(|entry| !entry.executed)
5203                {
5204                    if let Some(pending) = known_subjects.get(candidate.url.as_str()) {
5205                        current_layer.push(pending.clone());
5206                    } else {
5207                        reasons.insert(WebAssessmentIncompleteReason::SubjectExecutionIncomplete);
5208                        stop = true;
5209                    }
5210                }
5211            }
5212            if stop {
5213                break;
5214            }
5215        }
5216
5217        // Preserve retained-but-unexecuted subjects after a global stop.
5218        let reported: BTreeSet<_> = subject_reports
5219            .iter()
5220            .map(|report| report.subject.url.to_string())
5221            .collect();
5222        for subject in known_subjects.values().cloned() {
5223            if !reported.contains(subject.url.as_str()) {
5224                subject_reports.push(WebAssessmentSubjectReport::pending(subject));
5225            }
5226        }
5227        subject_reports.sort_by(|left, right| {
5228            left.subject
5229                .depth
5230                .cmp(&right.subject.depth)
5231                .then_with(|| left.subject.url.as_str().cmp(right.subject.url.as_str()))
5232        });
5233        forms.sort_by(|left, right| {
5234            left.action
5235                .as_str()
5236                .cmp(right.action.as_str())
5237                .then_with(|| left.method.cmp(&right.method))
5238        });
5239        match self.reconcile_final_state(&subject_reports, &forms, &known_subjects) {
5240            Ok(true) => {
5241                reasons.insert(WebAssessmentIncompleteReason::SubjectExecutionIncomplete);
5242            },
5243            Ok(false) => {},
5244            Err(()) => {
5245                let current_subject = subject_reports
5246                    .pop()
5247                    .unwrap_or_else(|| WebAssessmentSubjectReport::pending(self.root.clone()));
5248                let completed_subjects = subject_reports
5249                    .into_iter()
5250                    .filter(WebAssessmentSubjectReport::was_executed)
5251                    .collect();
5252                return Err(WebAssessmentRuntimeError::ProjectionInvariant {
5253                    receipt: self.failure_receipt(
5254                        &known_subjects,
5255                        completed_subjects,
5256                        forms,
5257                        current_subject,
5258                        failed_reasons(&reasons),
5259                        started_at,
5260                    ),
5261                });
5262            },
5263        }
5264        self.publish_progress(
5265            WebAssessmentProgressCheckpoint::InventoryReconciled,
5266            progress_executed_subjects,
5267            progress_processed_subjects,
5268        );
5269        #[cfg(feature = "wordpress-review")]
5270        if reasons.is_empty() {
5271            let discovery_result = match self.wordpress_review.as_mut() {
5272                Some(review) if review.discovery_enabled() && review.page_scope().is_some() => {
5273                    let root_subject = EntityId::new(format!("endpoint:{}", self.root.url))
5274                        .map_err(|_| WebAssessmentRuntimeError::NativeReviewComposition)?;
5275                    Some(
5276                        boxed_optional_child(|| {
5277                            review.execute_discovery(
5278                                root_subject,
5279                                &self.authority,
5280                                timing.deadline(),
5281                            )
5282                        })
5283                        .await,
5284                    )
5285                },
5286                _ => None,
5287            };
5288            match discovery_result {
5289                None | Some(Ok(WordPressDiscoveryStop::Complete)) => {},
5290                Some(Ok(WordPressDiscoveryStop::Cancelled)) => {
5291                    reasons.insert(WebAssessmentIncompleteReason::HostCancellation);
5292                    reasons.insert(WebAssessmentIncompleteReason::WordPressReviewIncomplete);
5293                },
5294                Some(Ok(WordPressDiscoveryStop::DeadlineExceeded)) => {
5295                    reasons.insert(WebAssessmentIncompleteReason::WallTimeLimit);
5296                    reasons.insert(WebAssessmentIncompleteReason::WordPressReviewIncomplete);
5297                },
5298                Some(Ok(WordPressDiscoveryStop::RuntimeLimit(dimension))) => {
5299                    reasons.insert(reason_for_runtime_dimension(dimension));
5300                    reasons.insert(WebAssessmentIncompleteReason::WordPressReviewIncomplete);
5301                },
5302                Some(Ok(WordPressDiscoveryStop::DiscoveryResponseLimit))
5303                | Some(Err(
5304                    super::wordpress_discovery::WordPressDiscoveryExecutionError::CandidateIdentityLimitExceeded,
5305                )) => {
5306                    reasons.insert(WebAssessmentIncompleteReason::WordPressReviewIncomplete);
5307                },
5308                Some(Err(_)) => {
5309                    let current_subject = subject_reports
5310                        .pop()
5311                        .unwrap_or_else(|| WebAssessmentSubjectReport::pending(self.root.clone()));
5312                    let completed_subjects = subject_reports
5313                        .into_iter()
5314                        .filter(WebAssessmentSubjectReport::was_executed)
5315                        .collect();
5316                    return Err(WebAssessmentRuntimeError::ProjectionInvariant {
5317                        receipt: self.failure_receipt(
5318                            &known_subjects,
5319                            completed_subjects,
5320                            forms,
5321                            current_subject,
5322                            failed_reasons(&reasons),
5323                            started_at,
5324                        ),
5325                    });
5326                },
5327            }
5328        }
5329        #[cfg(feature = "wordpress-review")]
5330        if reasons.is_empty() {
5331            let fingerprint_result = match self.wordpress_review.as_mut() {
5332                Some(review) if review.asset_fingerprints_enabled() => {
5333                    let root_subject = EntityId::new(format!("endpoint:{}", self.root.url))
5334                        .map_err(|_| WebAssessmentRuntimeError::NativeReviewComposition)?;
5335                    Some(
5336                        boxed_optional_child(|| {
5337                            review.execute_asset_fingerprints(
5338                                root_subject,
5339                                &self.authority,
5340                                timing.deadline(),
5341                            )
5342                        })
5343                        .await,
5344                    )
5345                },
5346                _ => None,
5347            };
5348            match fingerprint_result {
5349                None | Some(Ok(WordPressAssetFingerprintStop::Complete)) => {},
5350                Some(Ok(WordPressAssetFingerprintStop::Cancelled)) => {
5351                    reasons.insert(WebAssessmentIncompleteReason::HostCancellation);
5352                    reasons.insert(WebAssessmentIncompleteReason::WordPressReviewIncomplete);
5353                },
5354                Some(Ok(WordPressAssetFingerprintStop::DeadlineExceeded)) => {
5355                    reasons.insert(WebAssessmentIncompleteReason::WallTimeLimit);
5356                    reasons.insert(WebAssessmentIncompleteReason::WordPressReviewIncomplete);
5357                },
5358                Some(Ok(
5359                    WordPressAssetFingerprintStop::RequestLimit
5360                    | WordPressAssetFingerprintStop::ResponseLimit
5361                    | WordPressAssetFingerprintStop::RuntimeLimit
5362                    | WordPressAssetFingerprintStop::RateLimited,
5363                )) => {
5364                    reasons.insert(WebAssessmentIncompleteReason::WordPressReviewIncomplete);
5365                },
5366                Some(Err(_)) => {
5367                    let current_subject = subject_reports
5368                        .pop()
5369                        .unwrap_or_else(|| WebAssessmentSubjectReport::pending(self.root.clone()));
5370                    let completed_subjects = subject_reports
5371                        .into_iter()
5372                        .filter(WebAssessmentSubjectReport::was_executed)
5373                        .collect();
5374                    return Err(WebAssessmentRuntimeError::ProjectionInvariant {
5375                        receipt: self.failure_receipt(
5376                            &known_subjects,
5377                            completed_subjects,
5378                            forms,
5379                            current_subject,
5380                            failed_reasons(&reasons),
5381                            started_at,
5382                        ),
5383                    });
5384                },
5385            }
5386        }
5387        #[cfg(feature = "graphql-review")]
5388        if self.graphql_review {
5389            if !reasons.is_empty() {
5390                reasons.insert(WebAssessmentIncompleteReason::GraphqlReviewIncomplete);
5391            } else {
5392                let root_subject = EntityId::new(format!("endpoint:{}", self.root.url()))
5393                    .map_err(|_| WebAssessmentRuntimeError::NativeReviewComposition)?;
5394                let parent_defense = self
5395                    .optional_child_parent_defense
5396                    .as_ref()
5397                    .ok_or(WebAssessmentRuntimeError::NativeReviewComposition)?;
5398                if !parent_defense.permits_optional_interaction(
5399                    &root_subject,
5400                    DefenseInteractionClass::DifferentialRead,
5401                ) {
5402                    // Enforced standing defense or rate limiting can only
5403                    // remove this optional child; it never creates a request.
5404                } else {
5405                    let hints = graphql_endpoint_hints(
5406                        self.root.url(),
5407                        self.authority.knowledge(),
5408                        subject_reports
5409                            .iter()
5410                            .filter(|report| report.was_executed())
5411                            .map(|report| report.subject().url().clone()),
5412                        forms.iter().map(|form| form.action().clone()),
5413                    );
5414                    match execute_graphql_review(
5415                        &self.authority,
5416                        self.root.url(),
5417                        hints,
5418                        self.limits.max_response_body_bytes(),
5419                        self.defense_enforcement,
5420                        {
5421                            #[cfg(feature = "secret-exposure-review")]
5422                            if self.secret_exposure_collector.is_some() {
5423                                GraphqlResponsePrivacy::ValueFree
5424                            } else {
5425                                GraphqlResponsePrivacy::HistoricalDigests
5426                            }
5427                            #[cfg(not(feature = "secret-exposure-review"))]
5428                            {
5429                                GraphqlResponsePrivacy::HistoricalDigests
5430                            }
5431                        },
5432                    )
5433                    .await
5434                    {
5435                        Ok(GraphqlRuntimeResult::NotEligible) => {},
5436                        Ok(GraphqlRuntimeResult::Complete(completed)) => {
5437                            let replayed = completed
5438                                .replay_defense(
5439                                    self.authority.knowledge(),
5440                                    self.defense_enforcement,
5441                                )
5442                                .map_err(|_| WebAssessmentRuntimeError::NativeReviewComposition)?;
5443                            if !completed.defense().exact_audit_eq(&replayed)
5444                                || self.defense_audit.append_controller(&replayed).is_err()
5445                            {
5446                                return Err(WebAssessmentRuntimeError::NativeReviewComposition);
5447                            }
5448                            if completed.review().outcome()
5449                                == crate::graphql_review::GraphqlReviewOutcome::Incomplete
5450                            {
5451                                reasons
5452                                    .insert(WebAssessmentIncompleteReason::GraphqlReviewIncomplete);
5453                            } else {
5454                                self.committed_graphql_review = Some(completed.into_review());
5455                            }
5456                        },
5457                        Ok(GraphqlRuntimeResult::Stopped(stopped)) => {
5458                            let replayed = stopped
5459                                .replay_defense(
5460                                    self.authority.knowledge(),
5461                                    self.defense_enforcement,
5462                                )
5463                                .map_err(|_| WebAssessmentRuntimeError::NativeReviewComposition)?;
5464                            if !stopped.defense().exact_audit_eq(&replayed)
5465                                || self.defense_audit.append_controller(&replayed).is_err()
5466                            {
5467                                return Err(WebAssessmentRuntimeError::NativeReviewComposition);
5468                            }
5469                            match stopped.stop() {
5470                                GraphqlRuntimeStop::Cancelled => {
5471                                    reasons.insert(WebAssessmentIncompleteReason::HostCancellation);
5472                                },
5473                                GraphqlRuntimeStop::RuntimeLimit(dimension) => {
5474                                    reasons.insert(reason_for_runtime_dimension(dimension));
5475                                },
5476                                GraphqlRuntimeStop::TransportIncomplete => {},
5477                            }
5478                            reasons.insert(WebAssessmentIncompleteReason::GraphqlReviewIncomplete);
5479                        },
5480                        Err(_) => {
5481                            let current_subject = subject_reports.pop().unwrap_or_else(|| {
5482                                WebAssessmentSubjectReport::pending(self.root.clone())
5483                            });
5484                            let completed_subjects = subject_reports
5485                                .into_iter()
5486                                .filter(WebAssessmentSubjectReport::was_executed)
5487                                .collect();
5488                            return Err(WebAssessmentRuntimeError::ProjectionInvariant {
5489                                receipt: self.failure_receipt(
5490                                    &known_subjects,
5491                                    completed_subjects,
5492                                    forms,
5493                                    current_subject,
5494                                    failed_reasons(&reasons),
5495                                    started_at,
5496                                ),
5497                            });
5498                        },
5499                    }
5500                }
5501            }
5502        }
5503        #[cfg(feature = "template-evaluation-review")]
5504        if self.template_evaluation_review {
5505            let transport = self.authority.request_accounting().dispatch_audit();
5506            let audit = template_evaluation_audit_from_committed_runtime(
5507                self.native_review.as_ref(),
5508                &transport,
5509            )
5510            .map_err(|_| WebAssessmentRuntimeError::NativeReviewComposition)?;
5511            if audit.outcome() == TemplateEvaluationReviewOutcome::Incomplete {
5512                reasons.insert(WebAssessmentIncompleteReason::DifferentialReviewIncomplete);
5513            }
5514            self.template_evaluation_review_audit = Some(audit);
5515        }
5516        #[cfg(not(feature = "normalization-resilience"))]
5517        let review_ledgers = self
5518            .native_review
5519            .iter()
5520            .chain(self.non_root_structural_review.iter())
5521            .chain(self.xss_structural_review.iter())
5522            .map(|review| &review.ledger)
5523            .collect::<Vec<_>>();
5524        #[cfg(feature = "normalization-resilience")]
5525        let review_ledgers = self
5526            .native_review
5527            .iter()
5528            .chain(self.non_root_structural_review.iter())
5529            .chain(self.xss_structural_review.iter())
5530            .chain(self.normalization_review.iter())
5531            .map(|review| &review.ledger)
5532            .collect::<Vec<_>>();
5533        self.publish_progress(
5534            WebAssessmentProgressCheckpoint::ProjectionStarted,
5535            progress_executed_subjects,
5536            progress_processed_subjects,
5537        );
5538        #[cfg(feature = "wordpress-review")]
5539        if let Some(review) = self.wordpress_review.take() {
5540            if !reasons.is_empty() {
5541                reasons.insert(WebAssessmentIncompleteReason::WordPressReviewIncomplete);
5542            } else {
5543                let root_subject = EntityId::new(format!("endpoint:{}", self.root.url()))
5544                    .map_err(|_| WebAssessmentRuntimeError::NativeReviewComposition)?;
5545                match review.finish(root_subject) {
5546                    Ok(_) if self.authority.cancellation().is_cancelled() => {
5547                        reasons.insert(WebAssessmentIncompleteReason::HostCancellation);
5548                        reasons.insert(WebAssessmentIncompleteReason::WordPressReviewIncomplete);
5549                    },
5550                    Ok(_) if started_at.elapsed() >= self.limits.max_wall_time() => {
5551                        reasons.insert(WebAssessmentIncompleteReason::WallTimeLimit);
5552                        reasons.insert(WebAssessmentIncompleteReason::WordPressReviewIncomplete);
5553                    },
5554                    Ok(committed) => self.committed_wordpress_review = Some(committed),
5555                    Err(_) => {
5556                        reasons.insert(WebAssessmentIncompleteReason::WordPressReviewIncomplete);
5557                    },
5558                }
5559            }
5560        }
5561        let assessment_projection = match project_assessment_items(
5562            &self.passive_ledger,
5563            AssessmentReviewProjectionSources {
5564                native: &review_ledgers,
5565                api_visibility: self.committed_api_visibility.as_ref(),
5566                #[cfg(feature = "supplied-session-review")]
5567                supplied_session: self.supplied_session_audit.as_ref(),
5568                #[cfg(feature = "graphql-review")]
5569                graphql: self.committed_graphql_review.as_ref(),
5570                #[cfg(feature = "authorization-review")]
5571                authorization: self.committed_resource_authorization_review.as_ref(),
5572                #[cfg(feature = "openapi-review")]
5573                openapi: self.committed_openapi_review.as_ref(),
5574                #[cfg(feature = "rest-review")]
5575                rest: self.committed_rest_review.as_ref(),
5576                #[cfg(feature = "ssrf-oast-review")]
5577                ssrf_oast: self.committed_ssrf_oast_review.as_ref(),
5578                #[cfg(feature = "xml-external-entity-review")]
5579                xml_external_entity: self.committed_xml_external_entity_review.as_ref(),
5580                #[cfg(feature = "xml-external-entity-review")]
5581                xml_external_entity_selected: self.xml_external_entity_review_audit.is_some(),
5582                #[cfg(feature = "wordpress-review")]
5583                wordpress: self.committed_wordpress_review.as_ref(),
5584                #[cfg(feature = "secret-exposure-review")]
5585                secret_exposure: self
5586                    .secret_exposure_collector
5587                    .as_ref()
5588                    .map(|_| &self.secret_exposure_ledger),
5589                #[cfg(feature = "websocket-review")]
5590                websocket_selected: self.websocket_review.is_some()
5591                    || self.websocket_review_audit.is_some(),
5592            },
5593            self.authority.knowledge(),
5594            &self.root,
5595            &subject_reports
5596                .iter()
5597                .map(|report| report.subject().clone())
5598                .collect::<Vec<_>>(),
5599        ) {
5600            Ok(projection) => projection,
5601            Err(_) => {
5602                let current_subject = subject_reports
5603                    .pop()
5604                    .unwrap_or_else(|| WebAssessmentSubjectReport::pending(self.root.clone()));
5605                let completed_subjects = subject_reports
5606                    .into_iter()
5607                    .filter(WebAssessmentSubjectReport::was_executed)
5608                    .collect();
5609                return Err(WebAssessmentRuntimeError::ProjectionInvariant {
5610                    receipt: self.failure_receipt(
5611                        &known_subjects,
5612                        completed_subjects,
5613                        forms,
5614                        current_subject,
5615                        failed_reasons(&reasons),
5616                        started_at,
5617                    ),
5618                });
5619            },
5620        };
5621        let (assessment_items, assessment_projection_incompleteness) =
5622            assessment_projection.into_parts();
5623        if assessment_projection_incompleteness.is_incomplete() {
5624            reasons.insert(WebAssessmentIncompleteReason::AssessmentSubjectIdentityUnavailable);
5625        }
5626        let semantics = self.extract_semantics_and_refresh_limits(&mut reasons, started_at);
5627        #[cfg(feature = "tls-negotiation-review")]
5628        if self.tls_negotiation_review {
5629            self.tls_negotiation_review_audit = Some(if reasons.is_empty() {
5630                self.authority
5631                    .mint_tls_negotiation_review()
5632                    .map_err(|_| WebAssessmentRuntimeError::TlsNegotiationReviewComposition)?
5633                    .execute()
5634                    .await
5635            } else {
5636                WebAssessmentTlsNegotiationReviewAudit::parent_authority_unavailable()
5637            });
5638        }
5639        #[cfg(feature = "tls-negotiation-review")]
5640        self.refresh_parent_limits_after_tls_negotiation(&mut reasons, started_at);
5641        #[cfg(feature = "websocket-review")]
5642        if let Some(policy) = self.websocket_review.take() {
5643            self.websocket_review_audit = Some(if reasons.is_empty() {
5644                boxed_websocket_review(&self.authority, &policy).await
5645            } else {
5646                websocket_review_not_run(
5647                    &policy,
5648                    WebSocketReviewFailureKind::ParentAuthorityUnavailable,
5649                )
5650            });
5651        }
5652        #[cfg(feature = "websocket-review")]
5653        self.refresh_parent_limits_after_websocket(&mut reasons, started_at);
5654        #[cfg(feature = "recon-ct-provider")]
5655        let recon_ct_provider = if !reasons.is_empty() {
5656            // The incomplete-run diagnostic contract cannot carry a provider
5657            // receipt. Do not perform a third-party disclosure that the saved
5658            // result would then be unable to account for.
5659            None
5660        } else if let Some(policy) = self.recon_ct_provider.take() {
5661            // Provider-local partial, throttled, and failed outcomes remain a
5662            // first-class selected audit. They do not discard the otherwise
5663            // completed target assessment through the generic incomplete-run
5664            // fallback, which cannot carry this provider receipt.
5665            Some(execute_recon_ct_provider(policy, &self.root.url, &self.authority).await)
5666        } else {
5667            None
5668        };
5669        let usage = self.usage(
5670            subject_reports.len(),
5671            subject_reports
5672                .iter()
5673                .filter(|report| report.was_executed())
5674                .count(),
5675            forms.len(),
5676            started_at,
5677        );
5678        let completion = if reasons.is_empty() {
5679            WebAssessmentCompletion::Complete
5680        } else {
5681            WebAssessmentCompletion::Incomplete { reasons }
5682        };
5683        self.publish_progress(
5684            WebAssessmentProgressCheckpoint::FinishedSnapshot,
5685            usage.executed_subjects(),
5686            progress_processed_subjects,
5687        );
5688        Ok(WebAssessmentRunReport {
5689            #[cfg(feature = "reporting")]
5690            run_started_at,
5691            authorized_root: self.root.clone(),
5692            limits: self.limits,
5693            runtime_active_verification_limit: self.runtime_active_verification_limit,
5694            runtime_optional_active_verification_allowance: self
5695                .runtime_optional_active_verification_allowance,
5696            subjects: subject_reports,
5697            forms,
5698            semantics,
5699            defense: self.defense_audit.clone(),
5700            #[cfg(feature = "supplied-session-review")]
5701            supplied_session: self.supplied_session_audit.clone(),
5702            #[cfg(feature = "websocket-review")]
5703            websocket_review: self.websocket_review_audit.clone(),
5704            #[cfg(feature = "jwt-target-acceptance-review")]
5705            jwt_target_acceptance: self.jwt_target_acceptance_audit.clone(),
5706            #[cfg(feature = "xml-external-entity-review")]
5707            xml_external_entity_review: self.xml_external_entity_review_audit.clone(),
5708            #[cfg(feature = "template-evaluation-review")]
5709            template_evaluation_review: self.template_evaluation_review_audit.clone(),
5710            #[cfg(feature = "authorization-review")]
5711            authorization_review: self.authorization_review_audit.clone(),
5712            #[cfg(feature = "openapi-review")]
5713            openapi_review: self.openapi_review_audit.clone(),
5714            #[cfg(feature = "rest-review")]
5715            rest_review: self.rest_review_audit.clone(),
5716            #[cfg(feature = "ssrf-oast-review")]
5717            ssrf_oast_review: self.ssrf_oast_review_audit.clone(),
5718            #[cfg(feature = "wordpress-review")]
5719            wordpress_review: self
5720                .committed_wordpress_review
5721                .as_ref()
5722                .map(|review| review.audit().clone()),
5723            #[cfg(feature = "secret-exposure-review")]
5724            secret_exposure_review: self
5725                .secret_exposure_collector
5726                .as_ref()
5727                .map(|_| self.secret_exposure_ledger.audit()),
5728            #[cfg(feature = "tls-observation")]
5729            tls_observation: self.authority.tls_observation_audit(),
5730            #[cfg(feature = "tls-negotiation-review")]
5731            tls_negotiation_review: self.tls_negotiation_review_audit.clone(),
5732            #[cfg(feature = "recon-ct-provider")]
5733            recon_ct_provider,
5734            assessment_items,
5735            assessment_projection_incompleteness,
5736            completion,
5737            usage,
5738            transport: self.authority.request_accounting().dispatch_audit(),
5739        })
5740    }
5741
5742    fn reconcile_final_state(
5743        &self,
5744        subject_reports: &[WebAssessmentSubjectReport],
5745        forms: &[WebAssessmentForm],
5746        known_subjects: &BTreeMap<String, WebAssessmentSubject>,
5747    ) -> Result<bool, ()> {
5748        let report_subjects: BTreeMap<_, _> = subject_reports
5749            .iter()
5750            .map(|report| (report.subject.url.to_string(), report))
5751            .collect();
5752        if report_subjects.len() != subject_reports.len()
5753            || report_subjects.len() != known_subjects.len()
5754            || report_subjects.len() != self.ledger.subjects.len()
5755            || report_subjects.keys().ne(self.ledger.subjects.keys())
5756        {
5757            return Err(());
5758        }
5759        for (url, report) in &report_subjects {
5760            let admission = self.ledger.subjects.get(url).ok_or(())?;
5761            if report.was_executed() != admission.executed
5762                || report.subject.method != admission.method
5763                || report
5764                    .subject
5765                    .query_parameter_names
5766                    .iter()
5767                    .cloned()
5768                    .collect::<BTreeSet<_>>()
5769                    != admission.query_parameter_names
5770            {
5771                return Err(());
5772            }
5773        }
5774        let bootstrap_subjects: BTreeSet<_> = subject_reports
5775            .iter()
5776            .filter_map(|report| {
5777                report
5778                    .bootstrap()
5779                    .map(|receipt| receipt.case().subject().clone())
5780            })
5781            .collect();
5782        let passive_subjects: BTreeSet<_> = self
5783            .passive_ledger
5784            .observations()
5785            .iter()
5786            .map(|observation| observation.subject().clone())
5787            .collect();
5788        if bootstrap_subjects.len() != self.passive_ledger.observations().len()
5789            || bootstrap_subjects != passive_subjects
5790        {
5791            return Err(());
5792        }
5793        let form_identities: BTreeSet<_> = forms
5794            .iter()
5795            .map(|form| FormIdentity {
5796                action: form.action.to_string(),
5797                method: form.method,
5798            })
5799            .collect();
5800        if form_identities.len() != forms.len() || form_identities != self.ledger.form_identities {
5801            return Err(());
5802        }
5803        let retained_urls: BTreeSet<_> = subject_reports
5804            .iter()
5805            .map(|report| report.subject.url.to_string())
5806            .chain(forms.iter().map(|form| form.action.to_string()))
5807            .collect();
5808        let retained_unique_url_bytes = retained_urls.iter().map(String::len).sum::<usize>();
5809        if retained_urls != self.ledger.retained_urls
5810            || retained_unique_url_bytes != self.ledger.retained_unique_url_bytes
5811        {
5812            return Err(());
5813        }
5814        Ok(subject_reports.iter().any(|report| !report.was_executed()))
5815    }
5816
5817    fn run_failed(
5818        &mut self,
5819        known_subjects: &mut BTreeMap<String, WebAssessmentSubject>,
5820        completed_subjects: Vec<WebAssessmentSubjectReport>,
5821        mut forms: Vec<WebAssessmentForm>,
5822        boundary: FailedSubjectBoundary,
5823        reasons: &BTreeSet<WebAssessmentIncompleteReason>,
5824        started_at: tokio::time::Instant,
5825    ) -> WebAssessmentRuntimeError {
5826        let FailedSubjectBoundary {
5827            subject: current_subject,
5828            envelope,
5829            source,
5830            started: subject_started,
5831            defense,
5832            planner,
5833        } = boundary;
5834        let (parts, source) = source.into_assessment_failure();
5835        if subject_started {
5836            let replay = planner.as_ref().and_then(|planner| {
5837                let mut replay = replay_defense_parts(
5838                    parts.bootstrap.as_ref(),
5839                    &parts.turns,
5840                    None,
5841                    planner,
5842                    self.authority.knowledge(),
5843                    self.defense_enforcement,
5844                )
5845                .ok()?;
5846                if let Some(committed) = source.committed_evidence() {
5847                    replay
5848                        .ingest_receipt(
5849                            committed,
5850                            self.authority.knowledge(),
5851                            receipt_requires_defense_projection(committed),
5852                        )
5853                        .ok()?;
5854                }
5855                Some(replay)
5856            });
5857            let defense_valid = match (defense.as_ref(), replay) {
5858                (Some(expected), Some(replayed)) if replayed.exact_audit_eq(expected) => {
5859                    self.defense_audit.append_controller(&replayed).is_ok()
5860                },
5861                _ => false,
5862            };
5863            if !defense_valid {
5864                let current_report =
5865                    WebAssessmentSubjectReport::failed(current_subject, parts, true);
5866                return WebAssessmentRuntimeError::ProjectionInvariant {
5867                    receipt: self.failure_receipt(
5868                        known_subjects,
5869                        completed_subjects,
5870                        forms,
5871                        current_report,
5872                        failed_reasons(reasons),
5873                        started_at,
5874                    ),
5875                };
5876            }
5877        }
5878        let projection = projection_from_committed_bootstrap(
5879            parts.bootstrap.as_ref(),
5880            self.authority.knowledge(),
5881            &current_subject,
5882            &self.discovery_policy,
5883            self.limits,
5884            &envelope,
5885        );
5886        let current_report =
5887            WebAssessmentSubjectReport::failed(current_subject.clone(), parts, subject_started);
5888        let mut incomplete_reasons = failed_reasons(reasons);
5889        if self.authority.cancellation().is_cancelled() {
5890            incomplete_reasons.insert(WebAssessmentIncompleteReason::HostCancellation);
5891        }
5892        if started_at.elapsed() >= self.limits.max_wall_time() {
5893            incomplete_reasons.insert(WebAssessmentIncompleteReason::WallTimeLimit);
5894        }
5895        match projection {
5896            Ok(projection) => {
5897                if self
5898                    .replay_passive_bootstrap(
5899                        current_report.bootstrap(),
5900                        &current_subject,
5901                        &mut incomplete_reasons,
5902                    )
5903                    .is_err()
5904                {
5905                    let receipt = self.failure_receipt(
5906                        known_subjects,
5907                        completed_subjects,
5908                        forms,
5909                        current_report,
5910                        incomplete_reasons,
5911                        started_at,
5912                    );
5913                    return WebAssessmentRuntimeError::ProjectionInvariant { receipt };
5914                }
5915                if self
5916                    .semantic_evidence
5917                    .commit_bootstrap(
5918                        current_report.bootstrap(),
5919                        self.authority.knowledge(),
5920                        &current_subject,
5921                    )
5922                    .is_err()
5923                {
5924                    let receipt = self.failure_receipt(
5925                        known_subjects,
5926                        completed_subjects,
5927                        forms,
5928                        current_report,
5929                        incomplete_reasons,
5930                        started_at,
5931                    );
5932                    return WebAssessmentRuntimeError::ProjectionInvariant { receipt };
5933                }
5934                if let Some(projection) = projection {
5935                    projection.add_reasons(&mut incomplete_reasons);
5936                    self.ledger.apply(&projection);
5937                    forms.extend(projection.forms);
5938                    for route in projection.routes {
5939                        if self
5940                            .ledger
5941                            .subject_admission(&route.url)
5942                            .is_some_and(|entry| !entry.executed)
5943                        {
5944                            if let Some(existing) = known_subjects.get_mut(route.url.as_str()) {
5945                                merge_subject_route(
5946                                    existing,
5947                                    &route,
5948                                    current_subject.depth.saturating_add(1),
5949                                    self.limits.max_query_parameter_names(),
5950                                );
5951                            } else {
5952                                known_subjects.insert(
5953                                    route.url.to_string(),
5954                                    WebAssessmentSubject {
5955                                        url: route.url,
5956                                        method: route.method,
5957                                        depth: current_subject.depth.saturating_add(1),
5958                                        origin: WebAssessmentSubjectOrigin::Discovered,
5959                                        query_parameter_names: route.query_parameter_names,
5960                                        evidence_ids: route.evidence_ids,
5961                                    },
5962                                );
5963                            }
5964                        }
5965                    }
5966                }
5967            },
5968            Err(()) => {
5969                let receipt = self.failure_receipt(
5970                    known_subjects,
5971                    completed_subjects,
5972                    forms,
5973                    current_report,
5974                    incomplete_reasons,
5975                    started_at,
5976                );
5977                return WebAssessmentRuntimeError::ProjectionInvariant { receipt };
5978            },
5979        }
5980        let receipt = self.failure_receipt(
5981            known_subjects,
5982            completed_subjects,
5983            forms,
5984            current_report,
5985            incomplete_reasons,
5986            started_at,
5987        );
5988        if receipt.inventory_consistent() {
5989            WebAssessmentRuntimeError::RunFailed {
5990                receipt,
5991                source: Box::new(source),
5992            }
5993        } else {
5994            WebAssessmentRuntimeError::ProjectionInvariant { receipt }
5995        }
5996    }
5997
5998    fn failure_receipt(
5999        &self,
6000        known_subjects: &BTreeMap<String, WebAssessmentSubject>,
6001        completed_subjects: Vec<WebAssessmentSubjectReport>,
6002        forms: Vec<WebAssessmentForm>,
6003        current_subject: WebAssessmentSubjectReport,
6004        mut incomplete_reasons: BTreeSet<WebAssessmentIncompleteReason>,
6005        started_at: tokio::time::Instant,
6006    ) -> Box<WebAssessmentFailureReceipt> {
6007        let semantics =
6008            self.extract_semantics_and_refresh_limits(&mut incomplete_reasons, started_at);
6009        let completed_urls: BTreeSet<_> = completed_subjects
6010            .iter()
6011            .map(|report| report.subject.url.to_string())
6012            .collect();
6013        let mut pending_subjects: Vec<_> = known_subjects
6014            .values()
6015            .filter(|subject| {
6016                subject.url != current_subject.subject.url
6017                    && !completed_urls.contains(subject.url.as_str())
6018            })
6019            .cloned()
6020            .collect();
6021        pending_subjects.sort_by(|left, right| {
6022            left.depth
6023                .cmp(&right.depth)
6024                .then_with(|| left.url.as_str().cmp(right.url.as_str()))
6025        });
6026        let expected_executed: BTreeSet<_> = completed_subjects
6027            .iter()
6028            .filter(|report| report.was_executed())
6029            .map(|report| report.subject.url.to_string())
6030            .chain(
6031                current_subject
6032                    .was_executed()
6033                    .then(|| current_subject.subject.url.to_string()),
6034            )
6035            .collect();
6036        let unrepresented_ledger_subjects = self
6037            .ledger
6038            .subjects
6039            .keys()
6040            .filter(|url| !known_subjects.contains_key(url.as_str()))
6041            .count();
6042        let known_matches_ledger = known_subjects.len() == self.ledger.subjects.len()
6043            && known_subjects.iter().all(|(url, subject)| {
6044                self.ledger.subjects.get(url).is_some_and(|entry| {
6045                    entry.method == subject.method
6046                        && entry.query_parameter_names
6047                            == subject
6048                                .query_parameter_names
6049                                .iter()
6050                                .cloned()
6051                                .collect::<BTreeSet<_>>()
6052                        && entry.executed == expected_executed.contains(url)
6053                })
6054            });
6055        let retained_form_identities: BTreeSet<_> = forms
6056            .iter()
6057            .map(|form| FormIdentity {
6058                action: form.action.to_string(),
6059                method: form.method,
6060            })
6061            .collect();
6062        let inventory_consistent = known_matches_ledger
6063            && retained_form_identities.len() == forms.len()
6064            && retained_form_identities == self.ledger.form_identities;
6065        let executed_subjects = completed_subjects
6066            .iter()
6067            .filter(|report| report.was_executed())
6068            .count()
6069            .saturating_add(usize::from(current_subject.was_executed()));
6070        let mut usage = self.usage(
6071            completed_subjects
6072                .len()
6073                .saturating_add(1)
6074                .saturating_add(pending_subjects.len()),
6075            executed_subjects,
6076            forms.len(),
6077            started_at,
6078        );
6079        usage.retained_unique_url_bytes = completed_subjects
6080            .iter()
6081            .map(|report| report.subject.url.to_string())
6082            .chain(std::iter::once(current_subject.subject.url.to_string()))
6083            .chain(
6084                pending_subjects
6085                    .iter()
6086                    .map(|subject| subject.url.to_string()),
6087            )
6088            .chain(forms.iter().map(|form| form.action.to_string()))
6089            .collect::<BTreeSet<_>>()
6090            .iter()
6091            .map(String::len)
6092            .sum();
6093        Box::new(WebAssessmentFailureReceipt {
6094            completed_subjects,
6095            pending_subjects,
6096            forms,
6097            semantics,
6098            defense: self.defense_audit.clone(),
6099            #[cfg(feature = "supplied-session-review")]
6100            supplied_session: self.supplied_session_audit.clone(),
6101            #[cfg(feature = "websocket-review")]
6102            websocket_review: self.websocket_review_audit.clone(),
6103            #[cfg(feature = "jwt-target-acceptance-review")]
6104            jwt_target_acceptance: self.jwt_target_acceptance_audit.clone(),
6105            #[cfg(feature = "xml-external-entity-review")]
6106            xml_external_entity_review: self.xml_external_entity_review_audit.clone(),
6107            current_subject,
6108            incomplete_reasons,
6109            inventory_consistent,
6110            unrepresented_ledger_subjects,
6111            committed_passive_observations: self.passive_ledger.observations().len(),
6112            usage,
6113            transport: self.authority.request_accounting().dispatch_audit(),
6114        })
6115    }
6116
6117    fn usage(
6118        &self,
6119        retained_subjects: usize,
6120        executed_subjects: usize,
6121        retained_forms: usize,
6122        started_at: tokio::time::Instant,
6123    ) -> WebAssessmentUsage {
6124        let accounting = self.authority.request_accounting().snapshot();
6125        WebAssessmentUsage {
6126            retained_subjects,
6127            executed_subjects,
6128            retained_forms,
6129            retained_unique_url_bytes: self.ledger.retained_unique_url_bytes,
6130            total_requests: accounting.total_requests(),
6131            active_verifications: accounting.active_verifications(),
6132            request_body_bytes: accounting.request_body_bytes(),
6133            response_bytes: accounting.response_bytes(),
6134            elapsed_ms: duration_ms(started_at.elapsed()),
6135        }
6136    }
6137
6138    fn extract_semantics_and_refresh_limits(
6139        &self,
6140        reasons: &mut BTreeSet<WebAssessmentIncompleteReason>,
6141        started_at: tokio::time::Instant,
6142    ) -> SemanticExtractionResult {
6143        let semantics = self.semantic_evidence.extract(&self.semantic_limits);
6144        if semantics.truncated {
6145            reasons.insert(WebAssessmentIncompleteReason::SemanticExtractionLimit);
6146        }
6147        if self.authority.cancellation().is_cancelled() {
6148            reasons.insert(WebAssessmentIncompleteReason::HostCancellation);
6149        }
6150        if started_at.elapsed() >= self.limits.max_wall_time() {
6151            reasons.insert(WebAssessmentIncompleteReason::WallTimeLimit);
6152        }
6153        semantics
6154    }
6155
6156    /// Reconciles selected-matrix completeness and parent-global stop truth
6157    /// created while active TLS negotiation was executing. Completed failed
6158    /// handshake attempts remain ordinary matrix outcomes; a stop that leaves
6159    /// a network-capable row unattempted makes the selected review incomplete.
6160    #[cfg(feature = "tls-negotiation-review")]
6161    fn refresh_parent_limits_after_tls_negotiation(
6162        &self,
6163        reasons: &mut BTreeSet<WebAssessmentIncompleteReason>,
6164        started_at: tokio::time::Instant,
6165    ) {
6166        let Some(audit) = self.tls_negotiation_review_audit.as_ref() else {
6167            return;
6168        };
6169        if !audit.network_matrix_complete() {
6170            reasons.insert(WebAssessmentIncompleteReason::TlsNegotiationReviewIncomplete);
6171        }
6172        if self.authority.cancellation().is_cancelled() {
6173            reasons.insert(WebAssessmentIncompleteReason::HostCancellation);
6174        }
6175        if started_at.elapsed() >= self.limits.max_wall_time() {
6176            reasons.insert(WebAssessmentIncompleteReason::WallTimeLimit);
6177        }
6178        if audit.terminal() != "budget_exhausted" {
6179            return;
6180        }
6181        let accounting = self.authority.request_accounting().snapshot();
6182        if accounting.total_requests() >= self.limits.max_total_requests() {
6183            reasons.insert(WebAssessmentIncompleteReason::TotalRequestLimit);
6184        }
6185        if accounting.response_bytes() >= self.limits.max_total_response_bytes() {
6186            reasons.insert(WebAssessmentIncompleteReason::ResponseBytesLimit);
6187        }
6188        if accounting.active_verifications() >= self.runtime_active_verification_limit {
6189            reasons.insert(WebAssessmentIncompleteReason::ActiveVerificationLimit);
6190        }
6191    }
6192
6193    /// Reconciles only parent-global stop truth created while the optional
6194    /// WebSocket child was executing.
6195    ///
6196    /// A child-local handshake, protocol, expectation, or ten-second policy
6197    /// failure remains a first-class selected audit and does not make the
6198    /// otherwise completed assessment incomplete. Host cancellation, the
6199    /// parent wall deadline, and exhaustion of the shared request/response
6200    /// authority do. Refreshing those facts here also prevents a later
6201    /// third-party provider from starting after the parent has stopped.
6202    #[cfg(feature = "websocket-review")]
6203    fn refresh_parent_limits_after_websocket(
6204        &self,
6205        reasons: &mut BTreeSet<WebAssessmentIncompleteReason>,
6206        started_at: tokio::time::Instant,
6207    ) {
6208        let Some(audit) = self.websocket_review_audit.as_ref() else {
6209            return;
6210        };
6211        if self.authority.cancellation().is_cancelled() {
6212            reasons.insert(WebAssessmentIncompleteReason::HostCancellation);
6213        }
6214        if started_at.elapsed() >= self.limits.max_wall_time() {
6215            reasons.insert(WebAssessmentIncompleteReason::WallTimeLimit);
6216        }
6217        if audit.first_failure() == Some(WebSocketReviewFailureKind::ParentResponseBudgetReached) {
6218            reasons.insert(WebAssessmentIncompleteReason::ResponseBytesLimit);
6219        }
6220        if audit.first_failure() != Some(WebSocketReviewFailureKind::ParentAuthorityUnavailable)
6221            || audit.request_attempt_count() == 0
6222        {
6223            return;
6224        }
6225
6226        // A passive, zero-request-body WebSocket lease can be refused here
6227        // only by the parent's total-request or already-consumed response-byte
6228        // ceilings. Preserve either (or both) exact shared dimensions instead
6229        // of projecting the child's value-free refusal as a local failure.
6230        let accounting = self.authority.request_accounting().snapshot();
6231        if accounting.total_requests() >= self.limits.max_total_requests() {
6232            reasons.insert(WebAssessmentIncompleteReason::TotalRequestLimit);
6233        }
6234        if accounting.response_bytes() >= self.limits.max_total_response_bytes() {
6235            reasons.insert(WebAssessmentIncompleteReason::ResponseBytesLimit);
6236        }
6237    }
6238
6239    fn replay_passive_bootstrap(
6240        &mut self,
6241        receipt: Option<&DecisionEvidenceReceipt>,
6242        expected_subject: &WebAssessmentSubject,
6243        reasons: &mut BTreeSet<WebAssessmentIncompleteReason>,
6244    ) -> Result<(), ()> {
6245        let Some(receipt) = receipt else {
6246            return Ok(());
6247        };
6248        let observation = self.passive_ledger.ingest_receipt(
6249            receipt,
6250            self.authority.knowledge(),
6251            expected_subject,
6252        )?;
6253        #[cfg(feature = "secret-exposure-review")]
6254        if self.secret_exposure_collector.is_some() {
6255            self.secret_exposure_ledger.ingest_receipt(
6256                receipt,
6257                self.authority.knowledge(),
6258                expected_subject,
6259            )?;
6260        }
6261        if observation.is_some_and(|observation| observation.projection_incomplete()) {
6262            reasons.insert(WebAssessmentIncompleteReason::PassiveResponseProjectionLimit);
6263        }
6264        Ok(())
6265    }
6266}
6267
6268fn classify_standard_completion(
6269    report: &crate::StandardWebDecisionRunReport,
6270    reasons: &mut BTreeSet<WebAssessmentIncompleteReason>,
6271) {
6272    if let Some(limit) = report.limit_exceeded() {
6273        reasons.insert(reason_for_runtime_dimension(limit.dimension()));
6274    }
6275    match report.terminal() {
6276        DecisionLoopCommand::Complete { .. } => {},
6277        DecisionLoopCommand::AwaitHumanReview { .. } => {
6278            reasons.insert(WebAssessmentIncompleteReason::HumanReviewRequired);
6279        },
6280        DecisionLoopCommand::Halt { reason } => match reason {
6281            DecisionStopReason::ObjectiveComplete | DecisionStopReason::NoEligibleAction => {},
6282            DecisionStopReason::HumanReview => {
6283                reasons.insert(WebAssessmentIncompleteReason::HumanReviewRequired);
6284            },
6285            DecisionStopReason::ActionCycleLimit => {
6286                reasons.insert(WebAssessmentIncompleteReason::ActionCycleLimit);
6287            },
6288            DecisionStopReason::AdaptationLimit => {
6289                reasons.insert(WebAssessmentIncompleteReason::AdaptationLimit);
6290            },
6291            DecisionStopReason::CancelledByHost => {
6292                reasons.insert(WebAssessmentIncompleteReason::HostCancellation);
6293            },
6294            DecisionStopReason::RuntimeBudgetLimit => {
6295                if report.limit_exceeded().is_none() {
6296                    reasons.insert(WebAssessmentIncompleteReason::SubjectExecutionIncomplete);
6297                }
6298            },
6299        },
6300        DecisionLoopCommand::ExecuteAction { .. }
6301        | DecisionLoopCommand::CollectActiveEvidence { .. }
6302        | DecisionLoopCommand::Replan => {
6303            reasons.insert(WebAssessmentIncompleteReason::SubjectExecutionIncomplete);
6304        },
6305    }
6306}
6307
6308fn failed_reasons(
6309    reasons: &BTreeSet<WebAssessmentIncompleteReason>,
6310) -> BTreeSet<WebAssessmentIncompleteReason> {
6311    let mut failed = reasons.clone();
6312    failed.insert(WebAssessmentIncompleteReason::SubjectExecutionIncomplete);
6313    failed
6314}
6315
6316const fn reason_for_runtime_dimension(
6317    dimension: RuntimeBudgetDimension,
6318) -> WebAssessmentIncompleteReason {
6319    match dimension {
6320        RuntimeBudgetDimension::TotalRequests => WebAssessmentIncompleteReason::TotalRequestLimit,
6321        RuntimeBudgetDimension::WallTime => WebAssessmentIncompleteReason::WallTimeLimit,
6322        RuntimeBudgetDimension::ResponseBytes => WebAssessmentIncompleteReason::ResponseBytesLimit,
6323        RuntimeBudgetDimension::RequestBodyBytes => {
6324            WebAssessmentIncompleteReason::RequestBodyBytesLimit
6325        },
6326        RuntimeBudgetDimension::ActiveVerifications => {
6327            WebAssessmentIncompleteReason::ActiveVerificationLimit
6328        },
6329        RuntimeBudgetDimension::SameActionAttempts => {
6330            WebAssessmentIncompleteReason::SameActionAttemptLimit
6331        },
6332        RuntimeBudgetDimension::ConsecutiveNoProgressTurns => {
6333            WebAssessmentIncompleteReason::ConsecutiveNoProgressLimit
6334        },
6335    }
6336}
6337
6338#[derive(Clone)]
6339struct PendingRoute {
6340    url: Url,
6341    method: WebAssessmentMethod,
6342    depth: u16,
6343    query_parameter_names: Vec<String>,
6344    evidence_ids: Vec<EvidenceId>,
6345}
6346
6347fn merge_pending_route(
6348    routes: &mut BTreeMap<String, PendingRoute>,
6349    candidate: PendingRoute,
6350    depth: u16,
6351    query_name_limit: usize,
6352) {
6353    let key = candidate.url.to_string();
6354    match routes.get_mut(&key) {
6355        Some(existing) => {
6356            if candidate.method == WebAssessmentMethod::Get {
6357                existing.method = WebAssessmentMethod::Get;
6358            }
6359            existing.depth = existing.depth.min(depth);
6360            existing.query_parameter_names = existing
6361                .query_parameter_names
6362                .iter()
6363                .cloned()
6364                .chain(candidate.query_parameter_names)
6365                .collect::<BTreeSet<_>>()
6366                .into_iter()
6367                .take(query_name_limit)
6368                .collect();
6369            existing.evidence_ids = existing
6370                .evidence_ids
6371                .iter()
6372                .cloned()
6373                .chain(candidate.evidence_ids)
6374                .collect::<BTreeSet<_>>()
6375                .into_iter()
6376                .collect();
6377        },
6378        None => {
6379            routes.insert(key, PendingRoute { depth, ..candidate });
6380        },
6381    }
6382}
6383
6384fn merge_subject_route(
6385    subject: &mut WebAssessmentSubject,
6386    candidate: &PendingRoute,
6387    depth: u16,
6388    query_name_limit: usize,
6389) {
6390    if candidate.method == WebAssessmentMethod::Get {
6391        subject.method = WebAssessmentMethod::Get;
6392    }
6393    subject.depth = subject.depth.min(depth);
6394    subject.query_parameter_names = subject
6395        .query_parameter_names
6396        .iter()
6397        .cloned()
6398        .chain(candidate.query_parameter_names.iter().cloned())
6399        .collect::<BTreeSet<_>>()
6400        .into_iter()
6401        .take(query_name_limit)
6402        .collect();
6403    subject.evidence_ids = subject
6404        .evidence_ids
6405        .iter()
6406        .cloned()
6407        .chain(candidate.evidence_ids.iter().cloned())
6408        .collect::<BTreeSet<_>>()
6409        .into_iter()
6410        .collect();
6411}
6412
6413// Evidence-only observer and committed projection implementation.
6414
6415#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
6416struct FormIdentity {
6417    action: String,
6418    method: WebAssessmentFormMethod,
6419}
6420
6421#[derive(Clone)]
6422struct SubjectAdmission {
6423    method: WebAssessmentMethod,
6424    query_parameter_names: BTreeSet<String>,
6425    executed: bool,
6426}
6427
6428#[derive(Clone)]
6429struct AssessmentEnvelope {
6430    subjects: BTreeMap<String, SubjectAdmission>,
6431    form_identities: BTreeSet<FormIdentity>,
6432    retained_urls: BTreeSet<String>,
6433    remaining_subjects: usize,
6434    remaining_forms: usize,
6435    remaining_url_bytes: usize,
6436    allow_children: bool,
6437}
6438
6439struct AssessmentLedger {
6440    subjects: BTreeMap<String, SubjectAdmission>,
6441    form_identities: BTreeSet<FormIdentity>,
6442    retained_urls: BTreeSet<String>,
6443    retained_unique_url_bytes: usize,
6444}
6445
6446impl AssessmentLedger {
6447    fn new(root: &WebAssessmentSubject) -> Self {
6448        let root_url = root.url.to_string();
6449        Self {
6450            subjects: BTreeMap::from([(
6451                root_url.clone(),
6452                SubjectAdmission {
6453                    method: root.method,
6454                    query_parameter_names: root.query_parameter_names.iter().cloned().collect(),
6455                    executed: false,
6456                },
6457            )]),
6458            form_identities: BTreeSet::new(),
6459            retained_urls: BTreeSet::from([root_url.clone()]),
6460            retained_unique_url_bytes: root_url.len(),
6461        }
6462    }
6463
6464    fn snapshot(&self, limits: WebAssessmentLimits, subject_depth: u16) -> AssessmentEnvelope {
6465        AssessmentEnvelope {
6466            subjects: self.subjects.clone(),
6467            form_identities: self.form_identities.clone(),
6468            retained_urls: self.retained_urls.clone(),
6469            remaining_subjects: limits.max_subjects().saturating_sub(self.subjects.len()),
6470            remaining_forms: limits
6471                .max_forms()
6472                .saturating_sub(self.form_identities.len()),
6473            remaining_url_bytes: limits
6474                .max_retained_url_bytes()
6475                .saturating_sub(self.retained_unique_url_bytes),
6476            allow_children: subject_depth < limits.max_discovery_depth(),
6477        }
6478    }
6479
6480    fn mark_executed(&mut self, subject: &WebAssessmentSubject) -> Result<(), ()> {
6481        let entry = self.subjects.get_mut(subject.url.as_str()).ok_or(())?;
6482        entry.executed = true;
6483        entry.method = subject.method;
6484        entry.query_parameter_names = subject.query_parameter_names.iter().cloned().collect();
6485        Ok(())
6486    }
6487
6488    fn subject_admission(&self, url: &Url) -> Option<&SubjectAdmission> {
6489        self.subjects.get(url.as_str())
6490    }
6491
6492    fn apply(&mut self, projection: &DocumentProjection) {
6493        for route in &projection.routes {
6494            let key = route.url.to_string();
6495            match self.subjects.get_mut(&key) {
6496                Some(existing) if !existing.executed => {
6497                    if route.method == WebAssessmentMethod::Get {
6498                        existing.method = WebAssessmentMethod::Get;
6499                    }
6500                    existing
6501                        .query_parameter_names
6502                        .extend(route.query_parameter_names.iter().cloned());
6503                },
6504                Some(_) => {},
6505                None => {
6506                    self.subjects.insert(
6507                        key,
6508                        SubjectAdmission {
6509                            method: route.method,
6510                            query_parameter_names: route
6511                                .query_parameter_names
6512                                .iter()
6513                                .cloned()
6514                                .collect(),
6515                            executed: false,
6516                        },
6517                    );
6518                },
6519            }
6520            self.retain_url(&route.url);
6521        }
6522        for form in &projection.forms {
6523            self.form_identities.insert(FormIdentity {
6524                action: form.action.to_string(),
6525                method: form.method,
6526            });
6527            self.retain_url(&form.action);
6528        }
6529    }
6530
6531    fn retain_url(&mut self, url: &Url) {
6532        let value = url.to_string();
6533        if self.retained_urls.insert(value.clone()) {
6534            self.retained_unique_url_bytes =
6535                self.retained_unique_url_bytes.saturating_add(value.len());
6536        }
6537    }
6538}
6539
6540/// Exact allowlisted implementation of the sealed HTTP response observer.
6541///
6542/// The observer owns no mutable state. A fresh instance receives one immutable
6543/// runtime-ledger snapshot; only the decision runner can commit its output.
6544pub(crate) struct AssessmentDiscoveryObserver {
6545    policy: HttpEvidencePolicy,
6546    limits: WebAssessmentLimits,
6547    envelope: AssessmentEnvelope,
6548    expected_subject: String,
6549    expected_url: Url,
6550    expected_method: HttpProbeMethod,
6551    cancellation: CancellationToken,
6552    deadline: Option<tokio::time::Instant>,
6553    #[cfg(feature = "wordpress-review")]
6554    wordpress_signals: Option<WordPressSignalCollector>,
6555    #[cfg(feature = "wordpress-review")]
6556    wordpress_discovery_enabled: bool,
6557    #[cfg(feature = "wordpress-review")]
6558    wordpress_discovery_context: Option<WordPressDiscoveryContext>,
6559    #[cfg(feature = "wordpress-review")]
6560    wordpress_page_scope_selected: bool,
6561    #[cfg(feature = "wordpress-review")]
6562    wordpress_supplied_session_selected: bool,
6563    #[cfg(feature = "wordpress-review")]
6564    wordpress_page_candidate: Option<(WordPressPageCandidate, FrozenWordPressPageLayout)>,
6565    #[cfg(feature = "wordpress-review")]
6566    wordpress_asset_fingerprints: Option<WordPressAssetFingerprintCollector>,
6567    #[cfg(feature = "secret-exposure-review")]
6568    secret_exposure: Option<SecretExposureCollector>,
6569}
6570
6571impl AssessmentDiscoveryObserver {
6572    fn new(
6573        policy: HttpEvidencePolicy,
6574        limits: WebAssessmentLimits,
6575        envelope: AssessmentEnvelope,
6576        subject: &WebAssessmentSubject,
6577        cancellation: CancellationToken,
6578        deadline: Option<tokio::time::Instant>,
6579    ) -> Self {
6580        Self {
6581            policy,
6582            limits,
6583            envelope,
6584            expected_subject: format!("endpoint:{}", subject.url),
6585            expected_url: subject.url.clone(),
6586            expected_method: subject.method.probe_method(),
6587            cancellation,
6588            deadline,
6589            #[cfg(feature = "wordpress-review")]
6590            wordpress_signals: None,
6591            #[cfg(feature = "wordpress-review")]
6592            wordpress_discovery_enabled: false,
6593            #[cfg(feature = "wordpress-review")]
6594            wordpress_discovery_context: None,
6595            #[cfg(feature = "wordpress-review")]
6596            wordpress_page_scope_selected: false,
6597            #[cfg(feature = "wordpress-review")]
6598            wordpress_supplied_session_selected: false,
6599            #[cfg(feature = "wordpress-review")]
6600            wordpress_page_candidate: None,
6601            #[cfg(feature = "wordpress-review")]
6602            wordpress_asset_fingerprints: None,
6603            #[cfg(feature = "secret-exposure-review")]
6604            secret_exposure: None,
6605        }
6606    }
6607
6608    #[cfg(feature = "secret-exposure-review")]
6609    fn with_secret_exposure(mut self, collector: SecretExposureCollector) -> Self {
6610        self.secret_exposure = Some(collector);
6611        self
6612    }
6613
6614    #[cfg(feature = "wordpress-review")]
6615    fn with_wordpress_signals(
6616        mut self,
6617        collector: WordPressSignalCollector,
6618        discovery_enabled: bool,
6619        discovery_context: WordPressDiscoveryContext,
6620        page_scope_selected: bool,
6621        supplied_session_selected: bool,
6622        asset_fingerprints: Option<WordPressAssetFingerprintCollector>,
6623    ) -> Self {
6624        self.wordpress_signals = Some(collector);
6625        self.wordpress_discovery_enabled = discovery_enabled;
6626        self.wordpress_discovery_context = Some(discovery_context);
6627        self.wordpress_page_scope_selected = page_scope_selected;
6628        self.wordpress_supplied_session_selected = supplied_session_selected;
6629        self.wordpress_asset_fingerprints = asset_fingerprints;
6630        self
6631    }
6632
6633    #[cfg(feature = "wordpress-review")]
6634    fn with_wordpress_page_observation(
6635        mut self,
6636        collector: WordPressSignalCollector,
6637        candidate: WordPressPageCandidate,
6638        frozen_layout: FrozenWordPressPageLayout,
6639        asset_fingerprints: Option<WordPressAssetFingerprintCollector>,
6640    ) -> Self {
6641        self.wordpress_signals = Some(collector);
6642        self.wordpress_discovery_enabled = true;
6643        self.wordpress_page_scope_selected = true;
6644        self.wordpress_page_candidate = Some((candidate, frozen_layout));
6645        self.wordpress_asset_fingerprints = asset_fingerprints;
6646        self
6647    }
6648
6649    #[cfg(feature = "wordpress-review")]
6650    fn with_wordpress_asset_observation(
6651        mut self,
6652        collector: WordPressAssetFingerprintCollector,
6653    ) -> Self {
6654        self.wordpress_asset_fingerprints = Some(collector);
6655        self
6656    }
6657
6658    fn stopped(&self) -> bool {
6659        self.cancellation.is_cancelled()
6660            || self
6661                .deadline
6662                .is_some_and(|deadline| tokio::time::Instant::now() >= deadline)
6663    }
6664
6665    fn admit_document(&self, parsed: ParsedDocument) -> AdmittedDocument {
6666        let mut admitted = AdmittedDocument {
6667            route_limit_reached: parsed.route_limit_reached,
6668            form_limit_reached: parsed.form_limit_reached,
6669            control_limit_reached: parsed.control_limit_reached,
6670            query_name_limit_reached: parsed.query_name_limit_reached,
6671            url_byte_limit_reached: parsed.url_byte_limit_reached,
6672            outside_origin_reference_count: parsed.outside_origin_reference_count,
6673            ..AdmittedDocument::default()
6674        };
6675        if !self.envelope.allow_children {
6676            admitted.depth_limit_reached = !parsed.routes.is_empty() || !parsed.forms.is_empty();
6677            return admitted;
6678        }
6679
6680        let mut envelope = self.envelope.clone();
6681        // The parser has already merged GET form actions into the same
6682        // URL-first route prefix as anchors, areas, and link metadata routes.
6683        for route in parsed.routes {
6684            if self.policy.require_permitted_target(&route.url).is_err() {
6685                continue;
6686            }
6687            let key = route.url.to_string();
6688            if let Some(existing) = envelope.subjects.get_mut(&key) {
6689                if existing.executed {
6690                    continue;
6691                }
6692                let merged_method = if existing.method == WebAssessmentMethod::Get
6693                    || route.method == WebAssessmentMethod::Get
6694                {
6695                    WebAssessmentMethod::Get
6696                } else {
6697                    WebAssessmentMethod::Head
6698                };
6699                let method_changed = merged_method != existing.method;
6700                let mut admitted_names = Vec::new();
6701                for name in route.query_parameter_names {
6702                    if existing.query_parameter_names.contains(&name) {
6703                        continue;
6704                    }
6705                    if existing.query_parameter_names.len()
6706                        >= self.limits.max_query_parameter_names()
6707                    {
6708                        admitted.query_name_limit_reached = true;
6709                        continue;
6710                    }
6711                    existing.query_parameter_names.insert(name.clone());
6712                    admitted_names.push(name);
6713                }
6714                if !method_changed && admitted_names.is_empty() {
6715                    continue;
6716                }
6717                existing.method = merged_method;
6718                admitted.routes.push(ParsedRoute {
6719                    url: route.url,
6720                    method: route.method,
6721                    query_parameter_names: admitted_names,
6722                });
6723                continue;
6724            }
6725            if envelope.remaining_subjects == 0 {
6726                admitted.subject_limit_reached = true;
6727                break;
6728            }
6729            if !admit_unique_url(&mut envelope, &route.url) {
6730                admitted.retained_url_limit_reached = true;
6731                break;
6732            }
6733            envelope.remaining_subjects = envelope.remaining_subjects.saturating_sub(1);
6734            envelope.subjects.insert(
6735                key,
6736                SubjectAdmission {
6737                    method: route.method,
6738                    query_parameter_names: route.query_parameter_names.iter().cloned().collect(),
6739                    executed: false,
6740                },
6741            );
6742            admitted.routes.push(route);
6743        }
6744
6745        for form in parsed.forms {
6746            if self.policy.require_permitted_target(&form.action).is_err() {
6747                continue;
6748            }
6749            let identity = FormIdentity {
6750                action: form.action.to_string(),
6751                method: form.method,
6752            };
6753            if envelope.form_identities.contains(&identity) {
6754                continue;
6755            }
6756            if envelope.remaining_forms == 0 {
6757                admitted.form_limit_reached = true;
6758                break;
6759            }
6760            if !admit_unique_url(&mut envelope, &form.action) {
6761                admitted.retained_url_limit_reached = true;
6762                break;
6763            }
6764            envelope.remaining_forms = envelope.remaining_forms.saturating_sub(1);
6765            envelope.form_identities.insert(identity);
6766            admitted.forms.push(form);
6767        }
6768        admitted
6769    }
6770
6771    fn evidence_for_document(
6772        &self,
6773        observation: &CompleteHttpResponseObservation<'_>,
6774        admitted: AdmittedDocument,
6775        parents: Vec<EvidenceId>,
6776    ) -> Result<Vec<Evidence>, HttpEvidenceError> {
6777        let marker = derived_observation(
6778            observation,
6779            WebDiscoveryEvidencePredicate::DOCUMENT_PROJECTED,
6780            EvidenceValue::Boolean(true),
6781            "document-projected",
6782            parents,
6783        )?;
6784        let mut evidence = vec![marker.clone()];
6785
6786        for (condition, predicate, method) in [
6787            (
6788                admitted.route_limit_reached,
6789                WebDiscoveryEvidencePredicate::DOCUMENT_ROUTE_LIMIT_REACHED,
6790                "document-route-limit-reached",
6791            ),
6792            (
6793                admitted.form_limit_reached,
6794                WebDiscoveryEvidencePredicate::DOCUMENT_FORM_LIMIT_REACHED,
6795                "document-form-limit-reached",
6796            ),
6797            (
6798                admitted.control_limit_reached,
6799                WebDiscoveryEvidencePredicate::DOCUMENT_CONTROL_LIMIT_REACHED,
6800                "document-control-limit-reached",
6801            ),
6802            (
6803                admitted.query_name_limit_reached,
6804                WebDiscoveryEvidencePredicate::DOCUMENT_QUERY_NAME_LIMIT_REACHED,
6805                "document-query-name-limit-reached",
6806            ),
6807            (
6808                admitted.url_byte_limit_reached,
6809                WebDiscoveryEvidencePredicate::DOCUMENT_URL_BYTE_LIMIT_REACHED,
6810                "document-url-byte-limit-reached",
6811            ),
6812            (
6813                admitted.subject_limit_reached,
6814                WebDiscoveryEvidencePredicate::ASSESSMENT_SUBJECT_LIMIT_REACHED,
6815                "assessment-subject-limit-reached",
6816            ),
6817            (
6818                admitted.depth_limit_reached,
6819                WebDiscoveryEvidencePredicate::ASSESSMENT_DEPTH_LIMIT_REACHED,
6820                "assessment-depth-limit-reached",
6821            ),
6822            (
6823                admitted.retained_url_limit_reached,
6824                WebDiscoveryEvidencePredicate::ASSESSMENT_RETAINED_URL_BYTE_LIMIT_REACHED,
6825                "assessment-retained-url-byte-limit-reached",
6826            ),
6827        ] {
6828            if condition {
6829                evidence.push(derived_observation(
6830                    observation,
6831                    predicate,
6832                    EvidenceValue::Boolean(true),
6833                    method,
6834                    [marker.id().clone()],
6835                )?);
6836            }
6837        }
6838        if admitted.outside_origin_reference_count > 0 {
6839            evidence.push(derived_observation(
6840                observation,
6841                WebDiscoveryEvidencePredicate::DOCUMENT_OUTSIDE_ORIGIN_REFERENCE_COUNT,
6842                EvidenceValue::Unsigned(admitted.outside_origin_reference_count),
6843                "document-outside-origin-reference-count",
6844                [marker.id().clone()],
6845            )?);
6846        }
6847
6848        for route in admitted.routes {
6849            let predicate = match route.method {
6850                WebAssessmentMethod::Get => WebDiscoveryEvidencePredicate::GET_ROUTE,
6851                WebAssessmentMethod::Head => WebDiscoveryEvidencePredicate::HEAD_ROUTE,
6852            };
6853            let method = match route.method {
6854                WebAssessmentMethod::Get => "get-route",
6855                WebAssessmentMethod::Head => "head-route",
6856            };
6857            let parent = derived_observation(
6858                observation,
6859                predicate,
6860                EvidenceValue::Text(route.url.to_string()),
6861                method,
6862                [marker.id().clone()],
6863            )?;
6864            let parent_id = parent.id().clone();
6865            evidence.push(parent);
6866            if !route.query_parameter_names.is_empty() {
6867                evidence.push(derived_observation(
6868                    observation,
6869                    WebDiscoveryEvidencePredicate::ROUTE_QUERY_PARAMETER_NAMES,
6870                    EvidenceValue::TextList(route.query_parameter_names),
6871                    "route-query-parameter-names",
6872                    [parent_id],
6873                )?);
6874            }
6875        }
6876
6877        for form in admitted.forms {
6878            let (predicate, method) = match form.method {
6879                WebAssessmentFormMethod::Get => (
6880                    WebDiscoveryEvidencePredicate::GET_FORM_ACTION,
6881                    "get-form-action",
6882                ),
6883                WebAssessmentFormMethod::Post => (
6884                    WebDiscoveryEvidencePredicate::POST_FORM_ACTION,
6885                    "post-form-action",
6886                ),
6887                WebAssessmentFormMethod::Dialog => (
6888                    WebDiscoveryEvidencePredicate::DIALOG_FORM_ACTION,
6889                    "dialog-form-action",
6890                ),
6891            };
6892            let parent = derived_observation(
6893                observation,
6894                predicate,
6895                EvidenceValue::Text(form.action.to_string()),
6896                method,
6897                [marker.id().clone()],
6898            )?;
6899            let parent_id = parent.id().clone();
6900            evidence.push(parent);
6901            if !form.query_parameter_names.is_empty() {
6902                evidence.push(derived_observation(
6903                    observation,
6904                    WebDiscoveryEvidencePredicate::FORM_QUERY_PARAMETER_NAMES,
6905                    EvidenceValue::TextList(form.query_parameter_names),
6906                    "form-query-parameter-names",
6907                    [parent_id.clone()],
6908                )?);
6909            }
6910            if !form.control_names.is_empty() {
6911                evidence.push(derived_observation(
6912                    observation,
6913                    WebDiscoveryEvidencePredicate::FORM_CONTROL_NAMES,
6914                    EvidenceValue::TextList(form.control_names),
6915                    "form-control-names",
6916                    [parent_id],
6917                )?);
6918            }
6919        }
6920        Ok(evidence)
6921    }
6922}
6923
6924impl CompleteHttpResponseObserver for AssessmentDiscoveryObserver {
6925    fn observe(
6926        &self,
6927        observation: CompleteHttpResponseObservation<'_>,
6928    ) -> Result<(Vec<Evidence>, ResponseBodyDigestDisposition), HttpEvidenceError> {
6929        if observation.case_id() != BOOTSTRAP_CASE_ID
6930            || observation.action_id() != BOOTSTRAP_ACTION_ID
6931            || observation.hypothesis_id() != BOOTSTRAP_HYPOTHESIS_ID
6932            || observation.has_payload_strategy()
6933            || !observation.applies_hypothesis_transition()
6934            || observation.stage() != DecisionExecutionStage::Passive
6935            || observation.subject().as_str() != self.expected_subject
6936            || observation.method() != self.expected_method
6937            || observation.requested_url() != &self.expected_url
6938            || observation.requested_url().query().is_some()
6939            || observation.requested_url().fragment().is_some()
6940            || self
6941                .policy
6942                .require_permitted_target(observation.requested_url())
6943                .is_err()
6944        {
6945            #[cfg(feature = "secret-exposure-review")]
6946            if self.secret_exposure.is_some() {
6947                return Err(HttpEvidenceError::AssessmentObserverInvariant {
6948                    invariant: "secret-exposure observer routing",
6949                });
6950            }
6951            return Ok((Vec::new(), ResponseBodyDigestDisposition::Retain));
6952        }
6953        // Passive metadata is value-free and body-independent. Emit it before
6954        // every HEAD, incomplete-body, non-HTML, or discovery eligibility exit
6955        // so missing body evidence can never erase observed response metadata.
6956        let mut evidence = project_assessment_passive_response(
6957            observation.passive_response_projection(),
6958            AssessmentPassiveProjectionContext {
6959                subject: observation.subject(),
6960                case_id: observation.case_id(),
6961                executor_id: HTTP_EVIDENCE_EXECUTOR_ID,
6962                reliability: observation.reliability(),
6963                parents: passive_projection_parents(&observation)?,
6964            },
6965        )?;
6966        #[cfg(feature = "secret-exposure-review")]
6967        let mut secret_exposure_lineage = None;
6968        #[cfg(feature = "secret-exposure-review")]
6969        if let Some(secret_exposure) = self.secret_exposure.as_ref() {
6970            let secret_exposure_parents = secret_exposure_projection_parents(&observation)?;
6971            let projected =
6972                secret_exposure.observe(&observation, secret_exposure_parents.clone())?;
6973            secret_exposure_lineage = secret_exposure_response_lineage_id(
6974                &projected,
6975                observation.subject(),
6976                observation.case_id(),
6977                &secret_exposure_parents,
6978            );
6979            let lineage_record_present = projected.iter().any(|item| {
6980                item.predicate().namespace() == SECRET_EXPOSURE_NAMESPACE
6981                    && item.predicate().name() == RESPONSE_BODY_LINEAGE
6982            });
6983            if lineage_record_present != secret_exposure_lineage.is_some() {
6984                return Err(HttpEvidenceError::AssessmentObserverInvariant {
6985                    invariant: "secret-exposure response lineage",
6986                });
6987            }
6988            evidence.extend(projected);
6989        }
6990        #[cfg(not(feature = "secret-exposure-review"))]
6991        let secret_exposure_lineage: Option<EvidenceId> = None;
6992        #[cfg(feature = "wordpress-review")]
6993        if let Some(fingerprints) = self.wordpress_asset_fingerprints.as_ref() {
6994            if observation.method() == HttpProbeMethod::Get {
6995                fingerprints
6996                    .mark_ordinary_get_attempted(observation.requested_url())
6997                    .map_err(|_| HttpEvidenceError::AssessmentObserverInvariant {
6998                        invariant: "ordinary WordPress asset GET history must remain bounded",
6999                    })?;
7000            }
7001            // An S04-selected ordinary response must not publish a second
7002            // unkeyed digest through opportunistic fingerprint reuse. The
7003            // dedicated WordPress-owned acquisition path remains unchanged and
7004            // retains its explicitly selected exact-byte fingerprint evidence.
7005            if secret_exposure_lineage.is_none() {
7006                evidence.extend(fingerprints.observe_selected_response(&observation)?);
7007            }
7008        }
7009        if self.expected_method == HttpProbeMethod::Head {
7010            return Ok(complete_observer_output(evidence, secret_exposure_lineage));
7011        }
7012        let Some(body) = observation.complete_body() else {
7013            evidence.push(derived_observation(
7014                &observation,
7015                WebDiscoveryEvidencePredicate::DOCUMENT_BODY_INCOMPLETE,
7016                EvidenceValue::Boolean(true),
7017                "document-body-incomplete",
7018                incomplete_projection_parents(&observation, secret_exposure_lineage.as_ref())?,
7019            )?);
7020            return Ok(complete_observer_output(evidence, secret_exposure_lineage));
7021        };
7022        if self.stopped() {
7023            return Ok(complete_observer_output(evidence, secret_exposure_lineage));
7024        }
7025        if observation.media_type() != Some("text/html")
7026            || !matches!(observation.status(), 200 | 206)
7027        {
7028            return Ok(complete_observer_output(evidence, secret_exposure_lineage));
7029        }
7030        let parents = complete_projection_parents(&observation, secret_exposure_lineage.as_ref())?;
7031        if observation.status() == 206 {
7032            evidence.push(derived_observation(
7033                &observation,
7034                WebDiscoveryEvidencePredicate::DOCUMENT_PARTIAL_REPRESENTATION,
7035                EvidenceValue::Boolean(true),
7036                "document-partial-representation",
7037                parents,
7038            )?);
7039            return Ok(complete_observer_output(evidence, secret_exposure_lineage));
7040        }
7041        let Ok(html) = std::str::from_utf8(body) else {
7042            evidence.push(derived_observation(
7043                &observation,
7044                WebDiscoveryEvidencePredicate::DOCUMENT_INVALID_UTF8,
7045                EvidenceValue::Boolean(true),
7046                "document-invalid-utf8",
7047                parents,
7048            )?);
7049            return Ok(complete_observer_output(evidence, secret_exposure_lineage));
7050        };
7051        // Every S04-observed body uses the dedicated value-free lineage above
7052        // and is never parsed by generic discovery or WordPress collectors.
7053        // The fixed catalogue is finite, so even a zero-match body may contain
7054        // another low-entropy secret; neither a public whole-body digest nor a
7055        // body-derived path/form/component projection may become its oracle.
7056        if secret_exposure_lineage.is_some() {
7057            return Ok(complete_observer_output(evidence, secret_exposure_lineage));
7058        }
7059        let parsed = parse_document(observation.requested_url(), html, self.limits);
7060        #[cfg(feature = "wordpress-review")]
7061        let wordpress_observation = self.wordpress_discovery_context.as_ref().map(|context| {
7062            extract_wordpress_entry_observation_with_asset_fingerprints(
7063                context,
7064                observation.requested_url(),
7065                html,
7066                observation.wordpress_rest_index_advertisement(),
7067                self.wordpress_discovery_enabled,
7068                self.wordpress_page_scope_selected,
7069                self.wordpress_asset_fingerprints.is_some(),
7070            )
7071        });
7072        #[cfg(feature = "wordpress-review")]
7073        let wordpress_page_observation =
7074            self.wordpress_page_candidate
7075                .as_ref()
7076                .map(|(candidate, frozen_layout)| {
7077                    let mut page_parents = parents.clone();
7078                    if let Some(final_url) = observation.response_final_url_evidence_id() {
7079                        if !page_parents.contains(final_url) {
7080                            page_parents.push(final_url.clone());
7081                        }
7082                    }
7083                    extract_wordpress_page_observation_with_asset_fingerprints(
7084                        candidate.clone(),
7085                        frozen_layout,
7086                        html,
7087                        page_parents,
7088                        self.wordpress_asset_fingerprints.is_some(),
7089                    )
7090                });
7091        if self.stopped() {
7092            return Ok(complete_observer_output(evidence, secret_exposure_lineage));
7093        }
7094        let admitted = self.admit_document(parsed);
7095        #[cfg(feature = "wordpress-review")]
7096        let wordpress_evidence = {
7097            let mut evidence_ids = parents.clone();
7098            if self.wordpress_page_scope_selected || self.wordpress_supplied_session_selected {
7099                if let Some(final_url) = observation.response_final_url_evidence_id() {
7100                    if !evidence_ids.contains(final_url) {
7101                        evidence_ids.push(final_url.clone());
7102                    }
7103                }
7104            }
7105            evidence_ids
7106        };
7107        #[cfg(feature = "wordpress-review")]
7108        let wordpress_fingerprint_evidence = self
7109            .wordpress_asset_fingerprints
7110            .as_ref()
7111            .map(|_| {
7112                complete_fingerprint_projection_parents(
7113                    &observation,
7114                    secret_exposure_lineage.as_ref(),
7115                )
7116            })
7117            .transpose()?;
7118        evidence.extend(self.evidence_for_document(&observation, admitted, parents)?);
7119        if self.stopped() {
7120            evidence.retain(|item| {
7121                item.predicate().namespace() == ASSESSMENT_PASSIVE_NAMESPACE || {
7122                    #[cfg(feature = "secret-exposure-review")]
7123                    {
7124                        item.predicate().namespace() == SECRET_EXPOSURE_NAMESPACE
7125                    }
7126                    #[cfg(not(feature = "secret-exposure-review"))]
7127                    {
7128                        false
7129                    }
7130                }
7131            });
7132        } else {
7133            #[cfg(feature = "wordpress-review")]
7134            if let Some(collector) = self.wordpress_signals.as_ref() {
7135                if let Some(wordpress_observation) = wordpress_observation {
7136                    if let Some(fingerprints) = self.wordpress_asset_fingerprints.as_ref() {
7137                        fingerprints
7138                            .stage_candidates(
7139                                observation.requested_url(),
7140                                wordpress_observation.asset_fingerprint_candidates.clone(),
7141                                wordpress_fingerprint_evidence.clone().unwrap_or_default(),
7142                                wordpress_observation.asset_fingerprint_candidate_limit_exceeded,
7143                            )
7144                            .map_err(|_| HttpEvidenceError::AssessmentObserverInvariant {
7145                                invariant: "WordPress asset nominations must remain bounded",
7146                            })?;
7147                    }
7148                    if self.wordpress_page_scope_selected
7149                        || self.wordpress_supplied_session_selected
7150                    {
7151                        collector.stage_entry(wordpress_observation, wordpress_evidence);
7152                    } else {
7153                        collector.record(
7154                            wordpress_observation.signals,
7155                            wordpress_observation.discovery,
7156                            wordpress_evidence,
7157                        );
7158                    }
7159                }
7160                if let Some(wordpress_page_observation) = wordpress_page_observation {
7161                    if let Some(fingerprints) = self.wordpress_asset_fingerprints.as_ref() {
7162                        fingerprints
7163                            .stage_candidates(
7164                                observation.requested_url(),
7165                                wordpress_page_observation
7166                                    .asset_fingerprint_candidates
7167                                    .clone(),
7168                                wordpress_fingerprint_evidence.clone().unwrap_or_default(),
7169                                wordpress_page_observation
7170                                    .asset_fingerprint_candidate_limit_exceeded,
7171                            )
7172                            .map_err(|_| HttpEvidenceError::AssessmentObserverInvariant {
7173                                invariant: "WordPress page asset nominations must remain bounded",
7174                            })?;
7175                    }
7176                    collector.stage_page(wordpress_page_observation);
7177                }
7178            }
7179        }
7180        Ok(complete_observer_output(evidence, secret_exposure_lineage))
7181    }
7182}
7183
7184fn complete_observer_output(
7185    evidence: Vec<Evidence>,
7186    secret_exposure_lineage: Option<EvidenceId>,
7187) -> (Vec<Evidence>, ResponseBodyDigestDisposition) {
7188    match secret_exposure_lineage {
7189        Some(lineage_evidence_id) => (
7190            evidence,
7191            ResponseBodyDigestDisposition::Suppress {
7192                lineage_evidence_id,
7193            },
7194        ),
7195        None => (evidence, ResponseBodyDigestDisposition::Retain),
7196    }
7197}
7198
7199#[derive(Default)]
7200struct AdmittedDocument {
7201    routes: Vec<ParsedRoute>,
7202    forms: Vec<ParsedForm>,
7203    route_limit_reached: bool,
7204    form_limit_reached: bool,
7205    control_limit_reached: bool,
7206    query_name_limit_reached: bool,
7207    url_byte_limit_reached: bool,
7208    subject_limit_reached: bool,
7209    depth_limit_reached: bool,
7210    retained_url_limit_reached: bool,
7211    outside_origin_reference_count: u64,
7212}
7213
7214fn admit_unique_url(envelope: &mut AssessmentEnvelope, url: &Url) -> bool {
7215    let value = url.to_string();
7216    if envelope.retained_urls.contains(&value) {
7217        return true;
7218    }
7219    if value.len() > envelope.remaining_url_bytes {
7220        return false;
7221    }
7222    envelope.remaining_url_bytes -= value.len();
7223    envelope.retained_urls.insert(value);
7224    true
7225}
7226
7227fn complete_projection_parents(
7228    observation: &CompleteHttpResponseObservation<'_>,
7229    secret_exposure_lineage: Option<&EvidenceId>,
7230) -> Result<Vec<EvidenceId>, HttpEvidenceError> {
7231    let mut parents = [
7232        (
7233            observation.request_method_evidence_id(),
7234            "request-method-evidence",
7235        ),
7236        (
7237            observation.request_url_evidence_id(),
7238            "request-url-evidence",
7239        ),
7240        (
7241            observation.response_status_evidence_id(),
7242            "response-status-evidence",
7243        ),
7244        (
7245            observation.response_media_type_evidence_id(),
7246            "response-media-type-evidence",
7247        ),
7248        (
7249            observation.response_body_truncated_evidence_id(),
7250            "response-body-truncated-evidence",
7251        ),
7252    ]
7253    .into_iter()
7254    .map(|(id, invariant)| {
7255        id.cloned()
7256            .ok_or(HttpEvidenceError::AssessmentObserverInvariant { invariant })
7257    })
7258    .collect::<Result<Vec<_>, _>>()?;
7259    parents.push(body_content_lineage(observation, secret_exposure_lineage)?);
7260    Ok(parents)
7261}
7262
7263#[cfg(feature = "secret-exposure-review")]
7264fn secret_exposure_projection_parents(
7265    observation: &CompleteHttpResponseObservation<'_>,
7266) -> Result<Vec<EvidenceId>, HttpEvidenceError> {
7267    let mut parents = [
7268        (
7269            observation.request_method_evidence_id(),
7270            "request-method-evidence",
7271        ),
7272        (
7273            observation.request_url_evidence_id(),
7274            "request-url-evidence",
7275        ),
7276        (
7277            observation.response_status_evidence_id(),
7278            "response-status-evidence",
7279        ),
7280        (
7281            observation.response_final_url_evidence_id(),
7282            "response-final-url-evidence",
7283        ),
7284        (
7285            observation.response_body_bytes_evidence_id(),
7286            "response-body-byte-count-evidence",
7287        ),
7288        (
7289            observation.response_body_truncated_evidence_id(),
7290            "response-body-truncated-evidence",
7291        ),
7292    ]
7293    .into_iter()
7294    .map(|(id, invariant)| {
7295        id.cloned()
7296            .ok_or(HttpEvidenceError::AssessmentObserverInvariant { invariant })
7297    })
7298    .collect::<Result<Vec<_>, _>>()?;
7299    if let Some(media) = observation.response_media_type_evidence_id() {
7300        parents.push(media.clone());
7301    }
7302    Ok(parents)
7303}
7304
7305#[cfg(feature = "wordpress-review")]
7306fn complete_fingerprint_projection_parents(
7307    observation: &CompleteHttpResponseObservation<'_>,
7308    secret_exposure_lineage: Option<&EvidenceId>,
7309) -> Result<Vec<EvidenceId>, HttpEvidenceError> {
7310    let mut parents = [
7311        (
7312            observation.request_method_evidence_id(),
7313            "request-method-evidence",
7314        ),
7315        (
7316            observation.request_url_evidence_id(),
7317            "request-url-evidence",
7318        ),
7319        (
7320            observation.response_status_evidence_id(),
7321            "response-status-evidence",
7322        ),
7323        (
7324            observation.response_final_url_evidence_id(),
7325            "response-final-url-evidence",
7326        ),
7327        (
7328            observation.response_media_type_evidence_id(),
7329            "response-media-type-evidence",
7330        ),
7331        (
7332            observation.response_body_bytes_evidence_id(),
7333            "response-body-byte-count-evidence",
7334        ),
7335        (
7336            observation.response_body_truncated_evidence_id(),
7337            "response-body-truncated-evidence",
7338        ),
7339    ]
7340    .into_iter()
7341    .map(|(id, invariant)| {
7342        id.cloned()
7343            .ok_or(HttpEvidenceError::AssessmentObserverInvariant { invariant })
7344    })
7345    .collect::<Result<Vec<_>, _>>()?;
7346    parents.push(body_content_lineage(observation, secret_exposure_lineage)?);
7347    Ok(parents)
7348}
7349
7350fn passive_projection_parents(
7351    observation: &CompleteHttpResponseObservation<'_>,
7352) -> Result<Vec<EvidenceId>, HttpEvidenceError> {
7353    [
7354        (
7355            observation.request_method_evidence_id(),
7356            "request-method-evidence",
7357        ),
7358        (
7359            observation.request_url_evidence_id(),
7360            "request-url-evidence",
7361        ),
7362        (
7363            observation.response_status_evidence_id(),
7364            "response-status-evidence",
7365        ),
7366        (
7367            observation.response_final_url_evidence_id(),
7368            "response-final-url-evidence",
7369        ),
7370    ]
7371    .into_iter()
7372    .map(|(id, invariant)| {
7373        id.cloned()
7374            .ok_or(HttpEvidenceError::AssessmentObserverInvariant { invariant })
7375    })
7376    .collect()
7377}
7378
7379fn incomplete_projection_parents(
7380    observation: &CompleteHttpResponseObservation<'_>,
7381    secret_exposure_lineage: Option<&EvidenceId>,
7382) -> Result<Vec<EvidenceId>, HttpEvidenceError> {
7383    let mut parents = [
7384        (
7385            observation.request_method_evidence_id(),
7386            "request-method-evidence",
7387        ),
7388        (
7389            observation.request_url_evidence_id(),
7390            "request-url-evidence",
7391        ),
7392        (
7393            observation.response_status_evidence_id(),
7394            "response-status-evidence",
7395        ),
7396        (
7397            observation.response_body_truncated_evidence_id(),
7398            "response-body-truncated-evidence",
7399        ),
7400    ]
7401    .into_iter()
7402    .map(|(id, invariant)| {
7403        id.cloned()
7404            .ok_or(HttpEvidenceError::AssessmentObserverInvariant { invariant })
7405    })
7406    .collect::<Result<Vec<_>, _>>()?;
7407    parents.push(body_content_lineage(observation, secret_exposure_lineage)?);
7408    if let Some(media_id) = observation.response_media_type_evidence_id() {
7409        parents.push(media_id.clone());
7410    }
7411    Ok(parents)
7412}
7413
7414fn body_content_lineage(
7415    observation: &CompleteHttpResponseObservation<'_>,
7416    secret_exposure_lineage: Option<&EvidenceId>,
7417) -> Result<EvidenceId, HttpEvidenceError> {
7418    secret_exposure_lineage
7419        .cloned()
7420        .or_else(|| observation.response_body_digest_evidence_id().cloned())
7421        .ok_or(HttpEvidenceError::AssessmentObserverInvariant {
7422            invariant: "response-body-lineage-evidence",
7423        })
7424}
7425
7426fn derived_observation(
7427    observation: &CompleteHttpResponseObservation<'_>,
7428    predicate: PredicateDescriptor,
7429    value: EvidenceValue,
7430    method: &'static str,
7431    parents: impl IntoIterator<Item = EvidenceId>,
7432) -> Result<Evidence, HttpEvidenceError> {
7433    let source = EvidenceSource::new(HTTP_EVIDENCE_EXECUTOR_ID, method)?
7434        .with_correlation_id(observation.case_id())?;
7435    let derivation = EvidenceDerivation::new(
7436        parents,
7437        DerivationAlgorithm::new("web.discovery.html5ever-names-only", 1)?,
7438    )?;
7439    Ok(Evidence::new(
7440        observation.subject().clone(),
7441        EvidenceKind::Content,
7442        predicate.into(),
7443        value,
7444        source,
7445        observation.reliability(),
7446    )
7447    .derived_from(derivation))
7448}
7449
7450#[derive(Default)]
7451struct DocumentProjection {
7452    routes: Vec<PendingRoute>,
7453    forms: Vec<WebAssessmentForm>,
7454    explicit_reason: Option<WebAssessmentIncompleteReason>,
7455    subject_limit_reached: bool,
7456    depth_limit_reached: bool,
7457    route_limit_reached: bool,
7458    form_limit_reached: bool,
7459    control_limit_reached: bool,
7460    query_name_limit_reached: bool,
7461    canonical_url_byte_limit_reached: bool,
7462    retained_url_byte_limit_reached: bool,
7463}
7464
7465impl DocumentProjection {
7466    fn add_reasons(&self, reasons: &mut BTreeSet<WebAssessmentIncompleteReason>) {
7467        if let Some(reason) = self.explicit_reason {
7468            reasons.insert(reason);
7469        }
7470        for (condition, reason) in [
7471            (
7472                self.subject_limit_reached,
7473                WebAssessmentIncompleteReason::SubjectLimit,
7474            ),
7475            (
7476                self.depth_limit_reached,
7477                WebAssessmentIncompleteReason::DiscoveryDepthLimit,
7478            ),
7479            (
7480                self.route_limit_reached,
7481                WebAssessmentIncompleteReason::DocumentReferenceLimit,
7482            ),
7483            (
7484                self.form_limit_reached,
7485                WebAssessmentIncompleteReason::FormLimit,
7486            ),
7487            (
7488                self.control_limit_reached,
7489                WebAssessmentIncompleteReason::FormControlLimit,
7490            ),
7491            (
7492                self.query_name_limit_reached,
7493                WebAssessmentIncompleteReason::QueryParameterNameLimit,
7494            ),
7495            (
7496                self.canonical_url_byte_limit_reached,
7497                WebAssessmentIncompleteReason::CanonicalUrlBytesLimit,
7498            ),
7499            (
7500                self.retained_url_byte_limit_reached,
7501                WebAssessmentIncompleteReason::RetainedUrlBytesLimit,
7502            ),
7503        ] {
7504            if condition {
7505                reasons.insert(reason);
7506            }
7507        }
7508    }
7509}
7510
7511struct ProjectionBase {
7512    parent_ids: Vec<EvidenceId>,
7513    status: u16,
7514    body_truncated: bool,
7515}
7516
7517fn projection_from_committed_bootstrap(
7518    receipt: Option<&DecisionEvidenceReceipt>,
7519    knowledge: &KnowledgeBase,
7520    subject: &WebAssessmentSubject,
7521    policy: &HttpEvidencePolicy,
7522    limits: WebAssessmentLimits,
7523    envelope: &AssessmentEnvelope,
7524) -> Result<Option<DocumentProjection>, ()> {
7525    let Some(receipt) = receipt else {
7526        return Ok(None);
7527    };
7528    if receipt.case().id() != BOOTSTRAP_CASE_ID
7529        || receipt.case().action_id() != BOOTSTRAP_ACTION_ID
7530        || receipt.case().subject().as_str() != format!("endpoint:{}", subject.url)
7531        || receipt.case().hypothesis_id() != BOOTSTRAP_HYPOTHESIS_ID
7532        || receipt.case().payload_strategy().is_some()
7533        || !receipt.case().applies_hypothesis_transition()
7534        || receipt.executor_id() != HTTP_EVIDENCE_EXECUTOR_ID
7535        || receipt.stage() != DecisionExecutionStage::Passive
7536        || receipt.evidence().len() != receipt.writes().len()
7537    {
7538        return Err(());
7539    }
7540    for evidence in receipt.evidence() {
7541        if knowledge.evidence(evidence.id()).as_ref() != Some(evidence) {
7542            return Err(());
7543        }
7544    }
7545    let bootstrap_envelope = validate_bootstrap_request_envelope(receipt, subject)?;
7546
7547    let Some(first_discovery) = receipt
7548        .evidence()
7549        .iter()
7550        .position(|evidence| evidence.predicate().namespace() == "web.discovery")
7551    else {
7552        if subject.method == WebAssessmentMethod::Get && bootstrap_envelope.body_truncated {
7553            // A broker-truncated GET can only be represented by the sealed
7554            // observer's explicit incomplete-document evidence. Absence of
7555            // that marker is a committed-envelope invariant violation.
7556            return Err(());
7557        }
7558        return Ok(None);
7559    };
7560    if receipt.evidence()[..first_discovery]
7561        .iter()
7562        .any(|evidence| evidence.predicate().namespace() == ASSESSMENT_DEFENSE_NAMESPACE)
7563    {
7564        return Err(());
7565    }
7566    let discovery_tail = &receipt.evidence()[first_discovery..];
7567    let discovery_len = discovery_tail
7568        .iter()
7569        .position(|evidence| evidence.predicate().namespace() != "web.discovery")
7570        .unwrap_or(discovery_tail.len());
7571    let (discovery, trailing) = discovery_tail.split_at(discovery_len);
7572    // The HTTP evidence executor appends assessment-defense records after the
7573    // discovery observer's closed batch. Keep discovery replay strict while
7574    // admitting only that separately replay-validated suffix; an unknown or
7575    // interleaved evidence namespace still fails closed.
7576    if trailing
7577        .iter()
7578        .any(|evidence| evidence.predicate().namespace() != ASSESSMENT_DEFENSE_NAMESPACE)
7579    {
7580        return Err(());
7581    }
7582    let first = discovery.first().ok_or(())?;
7583
7584    if first.predicate()
7585        == &WebDiscoveryEvidencePredicate::DOCUMENT_BODY_INCOMPLETE.into_knowledge()
7586    {
7587        let base = projection_base(receipt, subject, false)?;
7588        if discovery.len() != 1 {
7589            return Err(());
7590        }
7591        validate_discovery_evidence(first, receipt, "document-body-incomplete", &base.parent_ids)?;
7592        require_true(first)?;
7593        return Ok(Some(DocumentProjection {
7594            explicit_reason: Some(WebAssessmentIncompleteReason::ResponseBodyIncomplete),
7595            ..DocumentProjection::default()
7596        }));
7597    }
7598    let base = projection_base(receipt, subject, true)?;
7599    if first.predicate()
7600        == &WebDiscoveryEvidencePredicate::DOCUMENT_PARTIAL_REPRESENTATION.into_knowledge()
7601    {
7602        if discovery.len() != 1 || base.status != 206 || base.body_truncated {
7603            return Err(());
7604        }
7605        validate_discovery_evidence(
7606            first,
7607            receipt,
7608            "document-partial-representation",
7609            &base.parent_ids,
7610        )?;
7611        require_true(first)?;
7612        return Ok(Some(DocumentProjection {
7613            explicit_reason: Some(WebAssessmentIncompleteReason::PartialRepresentation),
7614            ..DocumentProjection::default()
7615        }));
7616    }
7617    if first.predicate() == &WebDiscoveryEvidencePredicate::DOCUMENT_INVALID_UTF8.into_knowledge() {
7618        if discovery.len() != 1 || base.status != 200 || base.body_truncated {
7619            return Err(());
7620        }
7621        validate_discovery_evidence(first, receipt, "document-invalid-utf8", &base.parent_ids)?;
7622        require_true(first)?;
7623        return Ok(Some(DocumentProjection {
7624            explicit_reason: Some(WebAssessmentIncompleteReason::InvalidUtf8),
7625            ..DocumentProjection::default()
7626        }));
7627    }
7628    if base.status != 200
7629        || base.body_truncated
7630        || first.predicate() != &WebDiscoveryEvidencePredicate::DOCUMENT_PROJECTED.into_knowledge()
7631    {
7632        return Err(());
7633    }
7634    validate_discovery_evidence(first, receipt, "document-projected", &base.parent_ids)?;
7635    require_true(first)?;
7636    let marker_id = first.id().clone();
7637    let mut projection = DocumentProjection::default();
7638    let mut index = 1;
7639    let mut last_flag_rank = None;
7640
7641    while let Some(evidence) = discovery.get(index) {
7642        let Some((rank, flag)) = projection_flag(evidence.predicate()) else {
7643            break;
7644        };
7645        if last_flag_rank.is_some_and(|previous| rank <= previous) {
7646            return Err(());
7647        }
7648        validate_discovery_evidence(
7649            evidence,
7650            receipt,
7651            discovery_method(evidence.predicate()).ok_or(())?,
7652            std::slice::from_ref(&marker_id),
7653        )?;
7654        match flag {
7655            ProjectionFlag::OutsideOriginCount => {
7656                if !matches!(evidence.value(), EvidenceValue::Unsigned(value) if *value > 0) {
7657                    return Err(());
7658                }
7659            },
7660            flag => {
7661                require_true(evidence)?;
7662                set_projection_flag(&mut projection, flag)?;
7663            },
7664        }
7665        last_flag_rank = Some(rank);
7666        index += 1;
7667    }
7668
7669    let mut last_route_url: Option<String> = None;
7670    while let Some(evidence) = discovery.get(index) {
7671        let predicate = evidence.predicate();
7672        if predicate != &WebDiscoveryEvidencePredicate::GET_ROUTE.into_knowledge()
7673            && predicate != &WebDiscoveryEvidencePredicate::HEAD_ROUTE.into_knowledge()
7674        {
7675            break;
7676        }
7677        validate_discovery_evidence(
7678            evidence,
7679            receipt,
7680            discovery_method(predicate).ok_or(())?,
7681            std::slice::from_ref(&marker_id),
7682        )?;
7683        let url = canonical_evidence_url(evidence, policy, limits)?;
7684        let key = url.to_string();
7685        if last_route_url
7686            .as_ref()
7687            .is_some_and(|previous| previous >= &key)
7688        {
7689            return Err(());
7690        }
7691        last_route_url = Some(key);
7692        let method = if predicate == &WebDiscoveryEvidencePredicate::GET_ROUTE.into_knowledge() {
7693            WebAssessmentMethod::Get
7694        } else {
7695            WebAssessmentMethod::Head
7696        };
7697        let mut route = PendingRoute {
7698            url,
7699            method,
7700            depth: 0,
7701            query_parameter_names: Vec::new(),
7702            evidence_ids: vec![evidence.id().clone()],
7703        };
7704        let parent_id = evidence.id().clone();
7705        index += 1;
7706        if let Some(child) = discovery.get(index).filter(|child| {
7707            child.predicate()
7708                == &WebDiscoveryEvidencePredicate::ROUTE_QUERY_PARAMETER_NAMES.into_knowledge()
7709        }) {
7710            validate_discovery_evidence(
7711                child,
7712                receipt,
7713                "route-query-parameter-names",
7714                std::slice::from_ref(&parent_id),
7715            )?;
7716            route.query_parameter_names =
7717                valid_name_list(child.value(), limits.max_query_parameter_names())?;
7718            route.evidence_ids.push(child.id().clone());
7719            index += 1;
7720        }
7721        projection.routes.push(route);
7722    }
7723
7724    let mut last_form: Option<FormIdentity> = None;
7725    while let Some(evidence) = discovery.get(index) {
7726        if !is_form_action_predicate(evidence.predicate()) {
7727            return Err(());
7728        }
7729        validate_discovery_evidence(
7730            evidence,
7731            receipt,
7732            discovery_method(evidence.predicate()).ok_or(())?,
7733            std::slice::from_ref(&marker_id),
7734        )?;
7735        let action = canonical_evidence_url(evidence, policy, limits)?;
7736        let method = form_method_for_predicate(evidence.predicate()).ok_or(())?;
7737        let identity = FormIdentity {
7738            action: action.to_string(),
7739            method,
7740        };
7741        if last_form
7742            .as_ref()
7743            .is_some_and(|previous| previous >= &identity)
7744        {
7745            return Err(());
7746        }
7747        last_form = Some(identity);
7748        let parent_id = evidence.id().clone();
7749        let mut form = WebAssessmentForm {
7750            document_url: subject.url.clone(),
7751            action,
7752            method,
7753            query_parameter_names: Vec::new(),
7754            control_names: Vec::new(),
7755            evidence_ids: vec![parent_id.clone()],
7756        };
7757        index += 1;
7758        if let Some(child) = discovery.get(index).filter(|child| {
7759            child.predicate()
7760                == &WebDiscoveryEvidencePredicate::FORM_QUERY_PARAMETER_NAMES.into_knowledge()
7761        }) {
7762            validate_discovery_evidence(
7763                child,
7764                receipt,
7765                "form-query-parameter-names",
7766                std::slice::from_ref(&parent_id),
7767            )?;
7768            form.query_parameter_names =
7769                valid_name_list(child.value(), limits.max_query_parameter_names())?;
7770            form.evidence_ids.push(child.id().clone());
7771            index += 1;
7772        }
7773        if let Some(child) = discovery.get(index).filter(|child| {
7774            child.predicate() == &WebDiscoveryEvidencePredicate::FORM_CONTROL_NAMES.into_knowledge()
7775        }) {
7776            validate_discovery_evidence(
7777                child,
7778                receipt,
7779                "form-control-names",
7780                std::slice::from_ref(&parent_id),
7781            )?;
7782            form.control_names = valid_name_list(child.value(), limits.max_controls_per_form())?;
7783            form.evidence_ids.push(child.id().clone());
7784            index += 1;
7785        }
7786        projection.forms.push(form);
7787    }
7788
7789    validate_projection_envelope(&projection, envelope, limits)?;
7790    Ok(Some(projection))
7791}
7792
7793struct BootstrapRequestEnvelope {
7794    body_truncated: bool,
7795}
7796
7797fn validate_bootstrap_request_envelope(
7798    receipt: &DecisionEvidenceReceipt,
7799    subject: &WebAssessmentSubject,
7800) -> Result<BootstrapRequestEnvelope, ()> {
7801    let expected_method = match subject.method {
7802        WebAssessmentMethod::Get => "GET",
7803        WebAssessmentMethod::Head => "HEAD",
7804    };
7805    let mut body_truncated = None;
7806    for (descriptor, kind, source_method) in [
7807        (
7808            HttpEvidencePredicate::REQUEST_METHOD,
7809            EvidenceKind::Http,
7810            "request-method",
7811        ),
7812        (
7813            HttpEvidencePredicate::REQUEST_URL,
7814            EvidenceKind::Http,
7815            "request-url",
7816        ),
7817        (
7818            HttpEvidencePredicate::RESPONSE_STATUS,
7819            EvidenceKind::Http,
7820            "response-status",
7821        ),
7822        (
7823            HttpEvidencePredicate::RESPONSE_FINAL_URL,
7824            EvidenceKind::Http,
7825            "response-final-url",
7826        ),
7827        (
7828            HttpEvidencePredicate::RESPONSE_BODY_TRUNCATED,
7829            EvidenceKind::Content,
7830            "response-body-truncation",
7831        ),
7832    ] {
7833        let predicate = descriptor.into_knowledge();
7834        let mut matches = receipt
7835            .evidence()
7836            .iter()
7837            .filter(|evidence| evidence.predicate() == &predicate);
7838        let evidence = matches.next().ok_or(())?;
7839        if matches.next().is_some()
7840            || evidence.subject() != receipt.case().subject()
7841            || evidence.kind() != &kind
7842            || evidence.source().component() != HTTP_EVIDENCE_EXECUTOR_ID
7843            || evidence.source().method() != source_method
7844            || evidence.source().correlation_id() != Some(BOOTSTRAP_CASE_ID)
7845            || !matches!(evidence.origin(), EvidenceOrigin::Direct)
7846        {
7847            return Err(());
7848        }
7849        match descriptor {
7850            HttpEvidencePredicate::REQUEST_METHOD => {
7851                if evidence.value() != &EvidenceValue::Text(expected_method.to_owned()) {
7852                    return Err(());
7853                }
7854            },
7855            HttpEvidencePredicate::REQUEST_URL => {
7856                if evidence.value() != &EvidenceValue::Text(subject.url.to_string()) {
7857                    return Err(());
7858                }
7859            },
7860            HttpEvidencePredicate::RESPONSE_STATUS => {
7861                if !matches!(evidence.value(), EvidenceValue::Unsigned(value) if u16::try_from(*value).is_ok())
7862                {
7863                    return Err(());
7864                }
7865            },
7866            HttpEvidencePredicate::RESPONSE_FINAL_URL => {
7867                if evidence.value() != &EvidenceValue::Text(subject.url.to_string()) {
7868                    return Err(());
7869                }
7870            },
7871            HttpEvidencePredicate::RESPONSE_BODY_TRUNCATED => {
7872                let EvidenceValue::Boolean(value) = evidence.value() else {
7873                    return Err(());
7874                };
7875                body_truncated = Some(*value);
7876            },
7877            _ => return Err(()),
7878        }
7879    }
7880    response_body_lineage_from_receipt(receipt)?;
7881    let media_predicate = HttpEvidencePredicate::RESPONSE_MEDIA_TYPE.into_knowledge();
7882    let mut media_matches = receipt
7883        .evidence()
7884        .iter()
7885        .filter(|evidence| evidence.predicate() == &media_predicate);
7886    if let Some(media) = media_matches.next() {
7887        let EvidenceValue::Text(value) = media.value() else {
7888            return Err(());
7889        };
7890        if media_matches.next().is_some()
7891            || media.subject() != receipt.case().subject()
7892            || media.kind() != &EvidenceKind::Http
7893            || media.source().component() != HTTP_EVIDENCE_EXECUTOR_ID
7894            || media.source().method() != "response-media-type"
7895            || media.source().correlation_id() != Some(BOOTSTRAP_CASE_ID)
7896            || !matches!(media.origin(), EvidenceOrigin::Direct)
7897            || value.is_empty()
7898            || value != &value.to_ascii_lowercase()
7899            || value.contains(';')
7900            || value.chars().any(char::is_control)
7901        {
7902            return Err(());
7903        }
7904    }
7905    Ok(BootstrapRequestEnvelope {
7906        body_truncated: body_truncated.ok_or(())?,
7907    })
7908}
7909
7910fn valid_sha256(value: &str) -> bool {
7911    value.len() == 64
7912        && value
7913            .bytes()
7914            .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
7915}
7916
7917fn projection_base(
7918    receipt: &DecisionEvidenceReceipt,
7919    subject: &WebAssessmentSubject,
7920    require_html_media: bool,
7921) -> Result<ProjectionBase, ()> {
7922    let specs = [
7923        (
7924            HttpEvidencePredicate::REQUEST_METHOD,
7925            EvidenceKind::Http,
7926            "request-method",
7927        ),
7928        (
7929            HttpEvidencePredicate::REQUEST_URL,
7930            EvidenceKind::Http,
7931            "request-url",
7932        ),
7933        (
7934            HttpEvidencePredicate::RESPONSE_STATUS,
7935            EvidenceKind::Http,
7936            "response-status",
7937        ),
7938        (
7939            HttpEvidencePredicate::RESPONSE_BODY_TRUNCATED,
7940            EvidenceKind::Content,
7941            "response-body-truncation",
7942        ),
7943    ];
7944    let mut ids = Vec::with_capacity(specs.len());
7945    let mut status = None;
7946    let mut body_truncated = None;
7947    for (descriptor, kind, method) in specs {
7948        let predicate = descriptor.into_knowledge();
7949        let mut matches = receipt
7950            .evidence()
7951            .iter()
7952            .filter(|evidence| evidence.predicate() == &predicate);
7953        let evidence = matches.next().ok_or(())?;
7954        if matches.next().is_some()
7955            || evidence.subject() != receipt.case().subject()
7956            || evidence.kind() != &kind
7957            || evidence.source().component() != HTTP_EVIDENCE_EXECUTOR_ID
7958            || evidence.source().method() != method
7959            || evidence.source().correlation_id() != Some(BOOTSTRAP_CASE_ID)
7960            || !matches!(evidence.origin(), EvidenceOrigin::Direct)
7961        {
7962            return Err(());
7963        }
7964        match descriptor {
7965            HttpEvidencePredicate::REQUEST_METHOD => {
7966                if evidence.value() != &EvidenceValue::Text("GET".to_owned())
7967                    || subject.method != WebAssessmentMethod::Get
7968                {
7969                    return Err(());
7970                }
7971            },
7972            HttpEvidencePredicate::REQUEST_URL => {
7973                if evidence.value() != &EvidenceValue::Text(subject.url.to_string()) {
7974                    return Err(());
7975                }
7976            },
7977            HttpEvidencePredicate::RESPONSE_STATUS => {
7978                let EvidenceValue::Unsigned(value) = evidence.value() else {
7979                    return Err(());
7980                };
7981                status = Some(u16::try_from(*value).map_err(|_| ())?);
7982            },
7983            HttpEvidencePredicate::RESPONSE_BODY_TRUNCATED => {
7984                let EvidenceValue::Boolean(value) = evidence.value() else {
7985                    return Err(());
7986                };
7987                body_truncated = Some(*value);
7988            },
7989            _ => return Err(()),
7990        }
7991        ids.push(evidence.id().clone());
7992    }
7993    ids.push(response_body_lineage_from_receipt(receipt)?.id().clone());
7994    let media_predicate = HttpEvidencePredicate::RESPONSE_MEDIA_TYPE.into_knowledge();
7995    let mut media_matches = receipt
7996        .evidence()
7997        .iter()
7998        .filter(|evidence| evidence.predicate() == &media_predicate);
7999    let media = media_matches.next();
8000    if media_matches.next().is_some() || (require_html_media && media.is_none()) {
8001        return Err(());
8002    }
8003    if let Some(evidence) = media {
8004        let EvidenceValue::Text(value) = evidence.value() else {
8005            return Err(());
8006        };
8007        if evidence.subject() != receipt.case().subject()
8008            || evidence.kind() != &EvidenceKind::Http
8009            || evidence.source().component() != HTTP_EVIDENCE_EXECUTOR_ID
8010            || evidence.source().method() != "response-media-type"
8011            || evidence.source().correlation_id() != Some(BOOTSTRAP_CASE_ID)
8012            || !matches!(evidence.origin(), EvidenceOrigin::Direct)
8013            || (require_html_media && value != "text/html")
8014        {
8015            return Err(());
8016        }
8017        ids.push(evidence.id().clone());
8018    }
8019    ids.sort();
8020    Ok(ProjectionBase {
8021        parent_ids: ids,
8022        status: status.ok_or(())?,
8023        body_truncated: body_truncated.ok_or(())?,
8024    })
8025}
8026
8027fn response_body_lineage_from_receipt(receipt: &DecisionEvidenceReceipt) -> Result<&Evidence, ()> {
8028    let digest_predicate = HttpEvidencePredicate::RESPONSE_BODY_SHA256.into_knowledge();
8029    let digests = receipt
8030        .evidence()
8031        .iter()
8032        .filter(|evidence| evidence.predicate() == &digest_predicate)
8033        .collect::<Vec<_>>();
8034    #[cfg(feature = "secret-exposure-review")]
8035    let lineages = receipt
8036        .evidence()
8037        .iter()
8038        .filter(|evidence| {
8039            evidence.predicate().namespace() == SECRET_EXPOSURE_NAMESPACE
8040                && evidence.predicate().name() == RESPONSE_BODY_LINEAGE
8041        })
8042        .collect::<Vec<_>>();
8043    #[cfg(not(feature = "secret-exposure-review"))]
8044    let lineages: Vec<&Evidence> = Vec::new();
8045
8046    if let ([digest], []) = (digests.as_slice(), lineages.as_slice()) {
8047        let EvidenceValue::Text(value) = digest.value() else {
8048            return Err(());
8049        };
8050        if digest.subject() != receipt.case().subject()
8051            || digest.kind() != &EvidenceKind::Content
8052            || digest.source().component() != HTTP_EVIDENCE_EXECUTOR_ID
8053            || digest.source().method() != "response-body-sha256"
8054            || digest.source().correlation_id() != Some(BOOTSTRAP_CASE_ID)
8055            || !matches!(digest.origin(), EvidenceOrigin::Direct)
8056            || !valid_sha256(value)
8057        {
8058            return Err(());
8059        }
8060        return Ok(*digest);
8061    }
8062    #[cfg(feature = "secret-exposure-review")]
8063    {
8064        let expected_parent_ids = secret_exposure_response_parent_ids(
8065            receipt.evidence(),
8066            receipt.case().subject(),
8067            receipt.case().id(),
8068        )
8069        .ok_or(())?;
8070        match (digests.as_slice(), lineages.as_slice()) {
8071            ([], [lineage])
8072                if is_secret_exposure_response_lineage(
8073                    lineage,
8074                    receipt.case().subject(),
8075                    receipt.case().id(),
8076                    &expected_parent_ids,
8077                ) =>
8078            {
8079                Ok(*lineage)
8080            },
8081            _ => Err(()),
8082        }
8083    }
8084    #[cfg(not(feature = "secret-exposure-review"))]
8085    Err(())
8086}
8087
8088fn validate_discovery_evidence(
8089    evidence: &Evidence,
8090    receipt: &DecisionEvidenceReceipt,
8091    method: &str,
8092    expected_parents: &[EvidenceId],
8093) -> Result<(), ()> {
8094    if evidence.subject() != receipt.case().subject()
8095        || evidence.kind() != &EvidenceKind::Content
8096        || evidence.source().component() != HTTP_EVIDENCE_EXECUTOR_ID
8097        || evidence.source().method() != method
8098        || evidence.source().correlation_id() != Some(BOOTSTRAP_CASE_ID)
8099    {
8100        return Err(());
8101    }
8102    let derivation = evidence.origin().derivation().ok_or(())?;
8103    if derivation.algorithm().name() != "web.discovery.html5ever-names-only"
8104        || derivation.algorithm().version() != 1
8105        || derivation.parents() != expected_parents
8106    {
8107        return Err(());
8108    }
8109    Ok(())
8110}
8111
8112fn canonical_evidence_url(
8113    evidence: &Evidence,
8114    policy: &HttpEvidencePolicy,
8115    limits: WebAssessmentLimits,
8116) -> Result<Url, ()> {
8117    let EvidenceValue::Text(value) = evidence.value() else {
8118        return Err(());
8119    };
8120    let url = Url::parse(value).map_err(|_| ())?;
8121    if url.to_string() != *value
8122        || url.query().is_some()
8123        || url.fragment().is_some()
8124        || value.len() > limits.max_canonical_url_bytes()
8125        || policy.require_permitted_target(&url).is_err()
8126    {
8127        return Err(());
8128    }
8129    Ok(url)
8130}
8131
8132#[derive(Clone, Copy)]
8133enum ProjectionFlag {
8134    RouteLimit,
8135    FormLimit,
8136    ControlLimit,
8137    QueryNameLimit,
8138    CanonicalUrlByteLimit,
8139    SubjectLimit,
8140    DepthLimit,
8141    RetainedUrlByteLimit,
8142    OutsideOriginCount,
8143}
8144
8145fn projection_flag(predicate: &termivar_core::KnowledgePredicate) -> Option<(u8, ProjectionFlag)> {
8146    let ordered = [
8147        (
8148            WebDiscoveryEvidencePredicate::DOCUMENT_ROUTE_LIMIT_REACHED,
8149            ProjectionFlag::RouteLimit,
8150        ),
8151        (
8152            WebDiscoveryEvidencePredicate::DOCUMENT_FORM_LIMIT_REACHED,
8153            ProjectionFlag::FormLimit,
8154        ),
8155        (
8156            WebDiscoveryEvidencePredicate::DOCUMENT_CONTROL_LIMIT_REACHED,
8157            ProjectionFlag::ControlLimit,
8158        ),
8159        (
8160            WebDiscoveryEvidencePredicate::DOCUMENT_QUERY_NAME_LIMIT_REACHED,
8161            ProjectionFlag::QueryNameLimit,
8162        ),
8163        (
8164            WebDiscoveryEvidencePredicate::DOCUMENT_URL_BYTE_LIMIT_REACHED,
8165            ProjectionFlag::CanonicalUrlByteLimit,
8166        ),
8167        (
8168            WebDiscoveryEvidencePredicate::ASSESSMENT_SUBJECT_LIMIT_REACHED,
8169            ProjectionFlag::SubjectLimit,
8170        ),
8171        (
8172            WebDiscoveryEvidencePredicate::ASSESSMENT_DEPTH_LIMIT_REACHED,
8173            ProjectionFlag::DepthLimit,
8174        ),
8175        (
8176            WebDiscoveryEvidencePredicate::ASSESSMENT_RETAINED_URL_BYTE_LIMIT_REACHED,
8177            ProjectionFlag::RetainedUrlByteLimit,
8178        ),
8179        (
8180            WebDiscoveryEvidencePredicate::DOCUMENT_OUTSIDE_ORIGIN_REFERENCE_COUNT,
8181            ProjectionFlag::OutsideOriginCount,
8182        ),
8183    ];
8184    ordered
8185        .into_iter()
8186        .enumerate()
8187        .find_map(|(rank, (descriptor, flag))| {
8188            (predicate == &descriptor.into_knowledge()).then_some((rank as u8, flag))
8189        })
8190}
8191
8192fn set_projection_flag(
8193    projection: &mut DocumentProjection,
8194    flag: ProjectionFlag,
8195) -> Result<(), ()> {
8196    let target = match flag {
8197        ProjectionFlag::RouteLimit => &mut projection.route_limit_reached,
8198        ProjectionFlag::FormLimit => &mut projection.form_limit_reached,
8199        ProjectionFlag::ControlLimit => &mut projection.control_limit_reached,
8200        ProjectionFlag::QueryNameLimit => &mut projection.query_name_limit_reached,
8201        ProjectionFlag::CanonicalUrlByteLimit => &mut projection.canonical_url_byte_limit_reached,
8202        ProjectionFlag::SubjectLimit => &mut projection.subject_limit_reached,
8203        ProjectionFlag::DepthLimit => &mut projection.depth_limit_reached,
8204        ProjectionFlag::RetainedUrlByteLimit => &mut projection.retained_url_byte_limit_reached,
8205        ProjectionFlag::OutsideOriginCount => return Err(()),
8206    };
8207    if *target {
8208        return Err(());
8209    }
8210    *target = true;
8211    Ok(())
8212}
8213
8214fn require_true(evidence: &Evidence) -> Result<(), ()> {
8215    (evidence.value() == &EvidenceValue::Boolean(true))
8216        .then_some(())
8217        .ok_or(())
8218}
8219
8220fn discovery_method(predicate: &termivar_core::KnowledgePredicate) -> Option<&'static str> {
8221    let methods = [
8222        (
8223            WebDiscoveryEvidencePredicate::DOCUMENT_PROJECTED,
8224            "document-projected",
8225        ),
8226        (
8227            WebDiscoveryEvidencePredicate::DOCUMENT_BODY_INCOMPLETE,
8228            "document-body-incomplete",
8229        ),
8230        (
8231            WebDiscoveryEvidencePredicate::DOCUMENT_PARTIAL_REPRESENTATION,
8232            "document-partial-representation",
8233        ),
8234        (
8235            WebDiscoveryEvidencePredicate::DOCUMENT_INVALID_UTF8,
8236            "document-invalid-utf8",
8237        ),
8238        (
8239            WebDiscoveryEvidencePredicate::DOCUMENT_ROUTE_LIMIT_REACHED,
8240            "document-route-limit-reached",
8241        ),
8242        (
8243            WebDiscoveryEvidencePredicate::DOCUMENT_FORM_LIMIT_REACHED,
8244            "document-form-limit-reached",
8245        ),
8246        (
8247            WebDiscoveryEvidencePredicate::DOCUMENT_CONTROL_LIMIT_REACHED,
8248            "document-control-limit-reached",
8249        ),
8250        (
8251            WebDiscoveryEvidencePredicate::DOCUMENT_QUERY_NAME_LIMIT_REACHED,
8252            "document-query-name-limit-reached",
8253        ),
8254        (
8255            WebDiscoveryEvidencePredicate::DOCUMENT_URL_BYTE_LIMIT_REACHED,
8256            "document-url-byte-limit-reached",
8257        ),
8258        (
8259            WebDiscoveryEvidencePredicate::ASSESSMENT_SUBJECT_LIMIT_REACHED,
8260            "assessment-subject-limit-reached",
8261        ),
8262        (
8263            WebDiscoveryEvidencePredicate::ASSESSMENT_DEPTH_LIMIT_REACHED,
8264            "assessment-depth-limit-reached",
8265        ),
8266        (
8267            WebDiscoveryEvidencePredicate::ASSESSMENT_RETAINED_URL_BYTE_LIMIT_REACHED,
8268            "assessment-retained-url-byte-limit-reached",
8269        ),
8270        (
8271            WebDiscoveryEvidencePredicate::DOCUMENT_OUTSIDE_ORIGIN_REFERENCE_COUNT,
8272            "document-outside-origin-reference-count",
8273        ),
8274        (WebDiscoveryEvidencePredicate::GET_ROUTE, "get-route"),
8275        (WebDiscoveryEvidencePredicate::HEAD_ROUTE, "head-route"),
8276        (
8277            WebDiscoveryEvidencePredicate::ROUTE_QUERY_PARAMETER_NAMES,
8278            "route-query-parameter-names",
8279        ),
8280        (
8281            WebDiscoveryEvidencePredicate::GET_FORM_ACTION,
8282            "get-form-action",
8283        ),
8284        (
8285            WebDiscoveryEvidencePredicate::POST_FORM_ACTION,
8286            "post-form-action",
8287        ),
8288        (
8289            WebDiscoveryEvidencePredicate::DIALOG_FORM_ACTION,
8290            "dialog-form-action",
8291        ),
8292        (
8293            WebDiscoveryEvidencePredicate::FORM_QUERY_PARAMETER_NAMES,
8294            "form-query-parameter-names",
8295        ),
8296        (
8297            WebDiscoveryEvidencePredicate::FORM_CONTROL_NAMES,
8298            "form-control-names",
8299        ),
8300    ];
8301    methods.into_iter().find_map(|(descriptor, method)| {
8302        (predicate == &descriptor.into_knowledge()).then_some(method)
8303    })
8304}
8305
8306fn validate_projection_envelope(
8307    projection: &DocumentProjection,
8308    envelope: &AssessmentEnvelope,
8309    limits: WebAssessmentLimits,
8310) -> Result<(), ()> {
8311    if projection.routes.len() > limits.max_references_per_document()
8312        || projection.forms.len() > limits.max_forms()
8313        || (!envelope.allow_children
8314            && (!projection.routes.is_empty() || !projection.forms.is_empty()))
8315    {
8316        return Err(());
8317    }
8318    let mut replay = envelope.clone();
8319    for route in &projection.routes {
8320        let key = route.url.to_string();
8321        if let Some(existing) = replay.subjects.get_mut(&key) {
8322            if existing.executed {
8323                return Err(());
8324            }
8325            let merged_method = if existing.method == WebAssessmentMethod::Get
8326                || route.method == WebAssessmentMethod::Get
8327            {
8328                WebAssessmentMethod::Get
8329            } else {
8330                WebAssessmentMethod::Head
8331            };
8332            let method_changed = merged_method != existing.method;
8333            if route
8334                .query_parameter_names
8335                .iter()
8336                .any(|name| existing.query_parameter_names.contains(name))
8337                || existing
8338                    .query_parameter_names
8339                    .len()
8340                    .saturating_add(route.query_parameter_names.len())
8341                    > limits.max_query_parameter_names()
8342            {
8343                return Err(());
8344            }
8345            if !method_changed && route.query_parameter_names.is_empty() {
8346                return Err(());
8347            }
8348            existing.method = merged_method;
8349            existing
8350                .query_parameter_names
8351                .extend(route.query_parameter_names.iter().cloned());
8352            continue;
8353        }
8354        if replay.remaining_subjects == 0 || !admit_unique_url(&mut replay, &route.url) {
8355            return Err(());
8356        }
8357        replay.remaining_subjects -= 1;
8358        replay.subjects.insert(
8359            key,
8360            SubjectAdmission {
8361                method: route.method,
8362                query_parameter_names: route.query_parameter_names.iter().cloned().collect(),
8363                executed: false,
8364            },
8365        );
8366    }
8367    for form in &projection.forms {
8368        let identity = FormIdentity {
8369            action: form.action.to_string(),
8370            method: form.method,
8371        };
8372        if replay.form_identities.contains(&identity)
8373            || replay.remaining_forms == 0
8374            || !admit_unique_url(&mut replay, &form.action)
8375        {
8376            return Err(());
8377        }
8378        replay.remaining_forms -= 1;
8379        replay.form_identities.insert(identity);
8380    }
8381    Ok(())
8382}
8383
8384fn valid_name_list(value: &EvidenceValue, limit: usize) -> Result<Vec<String>, ()> {
8385    let EvidenceValue::TextList(names) = value else {
8386        return Err(());
8387    };
8388    if names.is_empty() || names.len() > limit {
8389        return Err(());
8390    }
8391    let canonical: BTreeSet<_> = names.iter().cloned().collect();
8392    if canonical.len() != names.len()
8393        || canonical.iter().ne(names)
8394        || names.iter().any(|name| {
8395            name.is_empty()
8396                || name.len() > HARD_MAX_DISCOVERY_NAME_BYTES
8397                || name.chars().any(char::is_control)
8398        })
8399    {
8400        return Err(());
8401    }
8402    Ok(names.clone())
8403}
8404
8405fn is_form_action_predicate(predicate: &termivar_core::KnowledgePredicate) -> bool {
8406    [
8407        WebDiscoveryEvidencePredicate::GET_FORM_ACTION,
8408        WebDiscoveryEvidencePredicate::POST_FORM_ACTION,
8409        WebDiscoveryEvidencePredicate::DIALOG_FORM_ACTION,
8410    ]
8411    .into_iter()
8412    .any(|item| predicate == &item.into_knowledge())
8413}
8414
8415fn form_method_for_predicate(
8416    predicate: &termivar_core::KnowledgePredicate,
8417) -> Option<WebAssessmentFormMethod> {
8418    if predicate == &WebDiscoveryEvidencePredicate::GET_FORM_ACTION.into_knowledge() {
8419        Some(WebAssessmentFormMethod::Get)
8420    } else if predicate == &WebDiscoveryEvidencePredicate::POST_FORM_ACTION.into_knowledge() {
8421        Some(WebAssessmentFormMethod::Post)
8422    } else if predicate == &WebDiscoveryEvidencePredicate::DIALOG_FORM_ACTION.into_knowledge() {
8423        Some(WebAssessmentFormMethod::Dialog)
8424    } else {
8425        None
8426    }
8427}
8428
8429#[cfg(test)]
8430mod websocket_parent_completion_tests {
8431    #[cfg(feature = "websocket-review")]
8432    mod enabled {
8433
8434        use std::{future::pending, sync::Arc, time::Duration};
8435
8436        use futures::StreamExt;
8437        use serde_json::json;
8438        use sha2::{Digest, Sha256};
8439        use tokio::{
8440            io::{AsyncReadExt, AsyncWriteExt},
8441            net::{TcpListener, TcpStream},
8442            sync::Notify,
8443            task::JoinHandle,
8444        };
8445        use tokio_tungstenite::accept_async;
8446        use url::Url;
8447
8448        use super::super::*;
8449
8450        struct ParentStopFixture {
8451            application: Url,
8452            endpoint: Url,
8453            message_seen: Arc<Notify>,
8454            task: JoinHandle<()>,
8455        }
8456
8457        impl Drop for ParentStopFixture {
8458            fn drop(&mut self) {
8459                self.task.abort();
8460            }
8461        }
8462
8463        async fn parent_stop_fixture(stall_after_message: bool) -> ParentStopFixture {
8464            let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
8465            let address = listener.local_addr().unwrap();
8466            let application = Url::parse(&format!("http://{address}/app/")).unwrap();
8467            let endpoint = Url::parse(&format!("ws://{address}/app/socket")).unwrap();
8468            let message_seen = Arc::new(Notify::new());
8469            let fixture_message_seen = Arc::clone(&message_seen);
8470            let task = tokio::spawn(async move {
8471                let (mut root, _) = listener.accept().await.unwrap();
8472                read_http_headers(&mut root).await;
8473                root.write_all(
8474                b"HTTP/1.1 200 OK\r\nContent-Type: text/html\r\nContent-Length: 4\r\nConnection: close\r\n\r\nroot",
8475            )
8476            .await
8477            .unwrap();
8478                root.shutdown().await.unwrap();
8479
8480                let (stream, _) = listener.accept().await.unwrap();
8481                let mut websocket = accept_async(stream).await.unwrap();
8482                if stall_after_message {
8483                    let _ = websocket.next().await;
8484                }
8485                fixture_message_seen.notify_one();
8486                pending::<()>().await;
8487            });
8488            ParentStopFixture {
8489                application,
8490                endpoint,
8491                message_seen,
8492                task,
8493            }
8494        }
8495
8496        async fn read_http_headers(stream: &mut TcpStream) {
8497            let mut bytes = Vec::new();
8498            while bytes.len() <= 16 * 1024 {
8499                let mut chunk = [0_u8; 1024];
8500                let read = stream.read(&mut chunk).await.unwrap();
8501                if read == 0 {
8502                    break;
8503                }
8504                bytes.extend_from_slice(&chunk[..read]);
8505                if bytes.windows(4).any(|window| window == b"\r\n\r\n") {
8506                    return;
8507                }
8508            }
8509            panic!("fixture did not receive one bounded HTTP request");
8510        }
8511
8512        fn websocket_policy(application: &Url, endpoint: &Url) -> WebSocketReviewPolicy {
8513            websocket_policy_with_wall_time(application, endpoint, 2_000)
8514        }
8515
8516        fn websocket_policy_with_wall_time(
8517            application: &Url,
8518            endpoint: &Url,
8519            max_wall_time_ms: u64,
8520        ) -> WebSocketReviewPolicy {
8521            let response = b"response";
8522            let source = serde_json::to_vec(&json!({
8523                "schema": "security.websocket-review-policy/v1",
8524                "target_authorized": true,
8525                "messages_read_only_acknowledged": true,
8526                "message_content_is_non_secret": true,
8527                "endpoint": endpoint.as_str(),
8528                "origin_mode": "application_origin",
8529                "subprotocol": null,
8530                "compression": false,
8531                "reconnect": false,
8532                "messages": [{
8533                    "id": "parent-stop",
8534                    "text": "request",
8535                    "expected_response": {
8536                        "sha256": format!("{:x}", Sha256::digest(response)),
8537                        "length": response.len(),
8538                    }
8539                }],
8540                "limits": {
8541                    "max_inbound_message_bytes": 4096,
8542                    "max_outbound_message_bytes": 4096,
8543                    "max_messages": 1,
8544                    "max_control_frames": 4,
8545                    "max_wall_time_ms": max_wall_time_ms,
8546                }
8547            }))
8548            .unwrap();
8549            WebSocketReviewPolicy::parse_json(application, &source).unwrap()
8550        }
8551
8552        fn assert_parent_incomplete(
8553            report: &WebAssessmentRunReport,
8554            expected: WebAssessmentIncompleteReason,
8555        ) {
8556            assert!(report.completion().reasons().contains(&expected));
8557            assert!(report.websocket_review_audit().is_some());
8558        }
8559
8560        #[cfg(feature = "reporting")]
8561        fn assert_incomplete_precedes_usage_validation(report: WebAssessmentRunReport) {
8562            let profile = ScanProfileV1::web_review()
8563                .unwrap()
8564                .with_limits(report.limits)
8565                .unwrap();
8566            let result = report.into_assessment_report(profile);
8567            assert!(
8568                matches!(
8569                    result.as_ref(),
8570                    Err(AssessmentRunReportError::AssessmentIncomplete)
8571                ),
8572                "unexpected conversion result: {:?}",
8573                result.as_ref().err()
8574            );
8575        }
8576
8577        #[test]
8578        fn websocket_parent_refresh_is_inert_when_option_is_absent() {
8579            let application = Url::parse("http://127.0.0.1:8123/app/").unwrap();
8580            let runtime = WebAssessmentRuntime::builder(application).build().unwrap();
8581            runtime.cancellation_token().cancel();
8582            let started_at = tokio::time::Instant::now()
8583                .checked_sub(runtime.limits.max_wall_time() + Duration::from_millis(1))
8584                .unwrap();
8585            let mut reasons = BTreeSet::new();
8586
8587            runtime.refresh_parent_limits_after_websocket(&mut reasons, started_at);
8588
8589            assert!(runtime.websocket_review_audit.is_none());
8590            assert!(reasons.is_empty());
8591        }
8592
8593        #[tokio::test]
8594        async fn websocket_host_cancellation_updates_outer_completion() {
8595            let fixture = parent_stop_fixture(true).await;
8596            let policy = websocket_policy(&fixture.application, &fixture.endpoint);
8597            let mut runtime = WebAssessmentRuntime::builder(fixture.application.clone())
8598                .with_websocket_review(policy)
8599                .build()
8600                .unwrap();
8601            let cancellation = runtime.cancellation_token();
8602            let message_seen = Arc::clone(&fixture.message_seen);
8603            let running = tokio::spawn(async move { runtime.analyze().await });
8604            tokio::time::timeout(Duration::from_secs(2), message_seen.notified())
8605                .await
8606                .unwrap();
8607            cancellation.cancel();
8608            let report = tokio::time::timeout(Duration::from_secs(2), running)
8609                .await
8610                .unwrap()
8611                .unwrap()
8612                .unwrap();
8613
8614            assert_parent_incomplete(&report, WebAssessmentIncompleteReason::HostCancellation);
8615            let audit = report.websocket_review_audit().unwrap();
8616            assert_eq!(
8617                audit.terminal(),
8618                crate::web_runtime::websocket_runtime::WebSocketReviewTerminal::Cancelled
8619            );
8620            assert_eq!(
8621                audit.first_failure(),
8622                Some(WebSocketReviewFailureKind::Cancelled)
8623            );
8624            #[cfg(feature = "reporting")]
8625            assert_incomplete_precedes_usage_validation(report);
8626        }
8627
8628        #[tokio::test]
8629        async fn websocket_parent_deadline_updates_outer_completion() {
8630            let fixture = parent_stop_fixture(true).await;
8631            let policy = websocket_policy(&fixture.application, &fixture.endpoint);
8632            let limits = WebAssessmentLimits::default()
8633                .with_max_wall_time(Duration::from_millis(200))
8634                .unwrap();
8635            let mut runtime = WebAssessmentRuntime::builder(fixture.application.clone())
8636                .limits(limits)
8637                .with_websocket_review(policy)
8638                .build()
8639                .unwrap();
8640            let report = tokio::time::timeout(Duration::from_secs(2), runtime.analyze())
8641                .await
8642                .unwrap()
8643                .unwrap();
8644
8645            assert_parent_incomplete(&report, WebAssessmentIncompleteReason::WallTimeLimit);
8646            let audit = report.websocket_review_audit().unwrap();
8647            assert_eq!(
8648                audit.terminal(),
8649                crate::web_runtime::websocket_runtime::WebSocketReviewTerminal::DeadlineReached
8650            );
8651            assert_eq!(
8652                audit.first_failure(),
8653                Some(WebSocketReviewFailureKind::DeadlineReached)
8654            );
8655            #[cfg(feature = "reporting")]
8656            assert_incomplete_precedes_usage_validation(report);
8657        }
8658
8659        #[tokio::test]
8660        async fn websocket_local_deadline_does_not_change_outer_completion() {
8661            let fixture = parent_stop_fixture(true).await;
8662            let policy =
8663                websocket_policy_with_wall_time(&fixture.application, &fixture.endpoint, 100);
8664            let mut runtime = WebAssessmentRuntime::builder(fixture.application.clone())
8665                .with_websocket_review(policy)
8666                .build()
8667                .unwrap();
8668            let report = tokio::time::timeout(Duration::from_secs(2), runtime.analyze())
8669                .await
8670                .unwrap()
8671                .unwrap();
8672
8673            assert!(matches!(
8674                report.completion(),
8675                WebAssessmentCompletion::Complete
8676            ));
8677            let audit = report.websocket_review_audit().unwrap();
8678            assert_eq!(
8679                audit.terminal(),
8680                crate::web_runtime::websocket_runtime::WebSocketReviewTerminal::DeadlineReached
8681            );
8682            assert_eq!(
8683                audit.first_failure(),
8684                Some(WebSocketReviewFailureKind::DeadlineReached)
8685            );
8686        }
8687
8688        #[tokio::test]
8689        async fn websocket_response_overrun_updates_outer_completion() {
8690            let fixture = parent_stop_fixture(false).await;
8691            let policy = websocket_policy(&fixture.application, &fixture.endpoint);
8692            let limits = WebAssessmentLimits::default()
8693                .with_max_total_response_bytes(64)
8694                .unwrap();
8695            let mut runtime = WebAssessmentRuntime::builder(fixture.application.clone())
8696                .limits(limits)
8697                .with_websocket_review(policy)
8698                .build()
8699                .unwrap();
8700            let report = tokio::time::timeout(Duration::from_secs(2), runtime.analyze())
8701                .await
8702                .unwrap()
8703                .unwrap();
8704
8705            assert_parent_incomplete(&report, WebAssessmentIncompleteReason::ResponseBytesLimit);
8706            let audit = report.websocket_review_audit().unwrap();
8707            assert_eq!(
8708                audit.first_failure(),
8709                Some(WebSocketReviewFailureKind::ParentResponseBudgetReached)
8710            );
8711            assert!(report.usage().response_bytes() >= limits.max_total_response_bytes());
8712            #[cfg(feature = "reporting")]
8713            assert_incomplete_precedes_usage_validation(report);
8714        }
8715
8716        #[tokio::test]
8717        async fn websocket_parent_request_refusal_updates_outer_completion() {
8718            let fixture = parent_stop_fixture(false).await;
8719            let policy = websocket_policy(&fixture.application, &fixture.endpoint);
8720            let limits = WebAssessmentLimits::default()
8721                .with_max_total_requests(1)
8722                .unwrap();
8723            let mut runtime = WebAssessmentRuntime::builder(fixture.application.clone())
8724                .limits(limits)
8725                .with_websocket_review(policy)
8726                .build()
8727                .unwrap();
8728            let report = tokio::time::timeout(Duration::from_secs(2), runtime.analyze())
8729                .await
8730                .unwrap()
8731                .unwrap();
8732
8733            assert_parent_incomplete(&report, WebAssessmentIncompleteReason::TotalRequestLimit);
8734            let audit = report.websocket_review_audit().unwrap();
8735            assert_eq!(audit.request_attempt_count(), 1);
8736            assert_eq!(audit.request_admitted_count(), 0);
8737            assert_eq!(
8738                audit.first_failure(),
8739                Some(WebSocketReviewFailureKind::ParentAuthorityUnavailable)
8740            );
8741            assert_eq!(report.usage().total_requests(), 1);
8742            #[cfg(feature = "reporting")]
8743            assert_incomplete_precedes_usage_validation(report);
8744        }
8745    }
8746}
8747
8748#[cfg(test)]
8749#[path = "web_assessment_tests.rs"]
8750mod tests;