Skip to main content

termivar_scanner/
web_runtime.rs

1//! Host-facing runtime for the standard deterministic web decision stack.
2//!
3//! ## Runtime scope
4//!
5//! - **Build:** default via `scanning`.
6//! - **Execution:** Surface B — this is `StandardWebDecisionRuntime`, invoked by
7//!   the canonical `termivar scan`, its deprecated `decision-scan` alias, the
8//!   `examples/decision_scan.rs` reference host, and external library hosts.
9//! - **Default `termivar scan`:** yes.
10//! - **Support:** implemented and tested.
11//!
12//! See `docs/internals/runtime-map.md`.
13//!
14//! The runtime owns composition and bounded command driving. Domain layers
15//! remain independently testable and the caller remains responsible for
16//! target authorization and HTTP evidence policy.
17
18use std::{collections::BTreeSet, future::Future, sync::Arc, time::Duration};
19
20use termivar_core::{
21    EntityId, EvidenceValue, HttpEvidencePredicate, OutcomeStatus, ReasoningModelError,
22    VerificationStage,
23};
24use thiserror::Error;
25use tokio_util::sync::CancellationToken;
26use url::Url;
27
28use crate::decision_runner::ContinuationAuthority;
29use crate::http_evidence::CompleteHttpResponseObserver;
30use crate::planner::ActionSuppressionContext;
31#[cfg(feature = "authorization-review")]
32use crate::rules::{Expression, KnowledgeLayer};
33use crate::{
34    AdaptationLimits, AdaptationRule, AdaptivePipelineError, BenefitScore, DecisionActionOrigin,
35    DecisionEvidenceReceipt, DecisionExecutionClass, DecisionExecutionFailureReceipt,
36    DecisionExecutionLimits, DecisionExecutionStage, DecisionExecutorRegistry, DecisionLoop,
37    DecisionLoopCommand, DecisionLoopConfig, DecisionLoopError, DecisionOutcomeReport,
38    DecisionPlanningReport, DecisionReasoningCommitReceipt, DecisionRunnerAdapter,
39    DecisionRunnerError, DecisionRunnerTurn, DecisionSession, DecisionStopReason, ExperiencePolicy,
40    ExperienceStore, ExperienceStoreError, HttpEvidenceError, HttpEvidenceExecutor,
41    HttpEvidencePolicy, HttpHeaderPayloadBinding, HttpProbe, HttpProbeMethod, KnowledgeBase,
42    KnowledgeWrite, OutcomeSelector, PipelineDirective, PlannerError, PlanningContext, RiskScore,
43    RuntimeBudget, RuntimeBudgetDimension, RuntimeLimitExceeded, RuntimeUsage,
44    StandardApiInstallReport, StandardApiReasoning, StandardApiReasoningError,
45    StandardWebActionKind, StandardWebDecisionError, StandardWebDecisionInstallReport,
46    StandardWebDecisionProfile, SubjectHttpProbeProvider, TransportDispatchAudit, VerificationCase,
47    VerificationError, HTTP_EVIDENCE_EXECUTOR_ID,
48};
49
50mod api_visibility;
51mod assessment_api_visibility;
52mod assessment_defense;
53mod assessment_item;
54mod assessment_passive;
55#[cfg(feature = "reporting")]
56mod assessment_report;
57mod assessment_review;
58mod assessment_review_projection;
59mod authority;
60#[cfg(feature = "graphql-review")]
61mod graphql_runtime;
62#[cfg(feature = "jwt-target-acceptance-review")]
63mod jwt_target_acceptance_runtime;
64#[cfg(feature = "openapi-review")]
65mod openapi_runtime;
66#[cfg(feature = "recon-ct-provider")]
67mod recon_ct_runtime;
68#[cfg(feature = "authorization-review")]
69mod resource_authorization_runtime;
70#[cfg(feature = "rest-review")]
71mod rest_runtime;
72mod scan_profile;
73#[cfg(feature = "secret-exposure-review")]
74mod secret_exposure;
75#[cfg(feature = "ssrf-oast-review")]
76mod ssrf_oast_runtime;
77#[cfg(feature = "supplied-session-review")]
78mod supplied_session_runtime;
79#[cfg(feature = "tls-negotiation-review")]
80mod tls_negotiation_review;
81#[cfg(feature = "tls-observation")]
82mod tls_observation;
83mod web_assessment;
84mod web_review_decision;
85mod web_review_execution;
86#[cfg(feature = "websocket-review")]
87mod websocket_runtime;
88#[cfg(feature = "wordpress-review")]
89mod wordpress_discovery;
90#[cfg(feature = "wordpress-review")]
91mod wordpress_fingerprint_runtime;
92#[cfg(feature = "wordpress-review")]
93mod wordpress_runtime;
94#[cfg(feature = "xml-external-entity-review")]
95mod xml_external_entity_runtime;
96
97pub use api_visibility::{
98    ApiVisibilityContextProbe, ApiVisibilityDifferentialAudit,
99    ApiVisibilityDifferentialDisposition, ApiVisibilityDifferentialRequest,
100    ApiVisibilityDifferentialRequestError, ApiVisibilityInconclusiveReason, ApiVisibilityLeg,
101    ApiVisibilityLegReceipt, RuntimeApiVisibilityError, RuntimeApiVisibilityExecutionError,
102    RuntimeApiVisibilityRunReport,
103};
104pub use assessment_api_visibility::{
105    WebAssessmentAuthorizationContextError, WebAssessmentRootAuthorizationContext,
106};
107use assessment_defense::AssessmentDefenseController;
108pub(crate) use assessment_defense::{
109    project_assessment_defense_signal, AssessmentDefenseBodyCoverage,
110    AssessmentDefenseProjectionContext, AssessmentDefenseSignal,
111};
112pub use assessment_item::{
113    AssessmentBasis, AssessmentCaseReference, AssessmentConfirmationDenial,
114    AssessmentDifferentialBasis, AssessmentDisposition, AssessmentEvidenceReference,
115    AssessmentItem, AssessmentItemProjectionError, AssessmentObservationBasis,
116    AssessmentOutcomeReference, AssessmentRemediation, AssessmentSubjectReference,
117    AssessmentVerifierBasis, ASSESSMENT_ITEM_SCHEMA, MAX_ASSESSMENT_CAPABILITY_ID_BYTES,
118    MAX_ASSESSMENT_DISPLAY_BYTES, MAX_ASSESSMENT_ITEM_EVIDENCE_REFERENCES,
119    MAX_ASSESSMENT_ITEM_SET_ITEMS,
120};
121pub(crate) use assessment_review::AssessmentReviewObserverSet;
122#[cfg(any(
123    feature = "authorization-review",
124    feature = "jwt-target-acceptance-review",
125    feature = "supplied-session-review"
126))]
127pub(crate) use authority::authenticated_transport_is_allowed;
128#[cfg(feature = "oast-native-provider")]
129pub(crate) use authority::NativeOastProviderMintToken;
130pub(crate) use authority::SharedWebRuntimeAuthority;
131#[cfg(feature = "supplied-session-review")]
132pub(crate) use authority::{OAuthCodeMintToken, OAuthRefreshMintToken};
133#[cfg(feature = "supplied-session-review")]
134pub(crate) use supplied_session_runtime::OAuthAuthorizationDispatchPermit;
135#[cfg(feature = "supplied-session-review")]
136pub(crate) use supplied_session_runtime::OAuthRefreshDispatchPermit;
137#[cfg(feature = "tls-negotiation-review")]
138pub use tls_negotiation_review::{
139    tls_negotiation_review_target_is_supported, WebAssessmentTlsNegotiationReviewAudit,
140    WebAssessmentTlsNegotiationReviewCell, TLS_NEGOTIATION_REVIEW_ACTION_TLS12,
141    TLS_NEGOTIATION_REVIEW_ACTION_TLS13, TLS_NEGOTIATION_REVIEW_AUDIT_SCHEMA,
142    TLS_NEGOTIATION_REVIEW_CAPABILITY_ID, TLS_NEGOTIATION_REVIEW_POLICY_ID,
143};
144#[cfg(feature = "tls-observation")]
145pub(crate) use tls_observation::TlsObservationCollector;
146pub(crate) use web_assessment::AssessmentDiscoveryObserver;
147use web_review_decision::NativeWebReviewDecisionProfile;
148use web_review_execution::{
149    NativeWebReviewExecutorProfile, NativeWebReviewQueryParameters, NativeWebReviewSeeds,
150};
151
152#[cfg(all(feature = "websocket-review", feature = "supplied-session-review"))]
153pub use websocket_runtime::WEBSOCKET_SUPPLIED_SESSION_AUDIT_SCHEMA;
154#[cfg(feature = "websocket-review")]
155pub use websocket_runtime::{
156    WebAssessmentWebSocketReviewAudit, WebSocketReviewClaimLimit, WebSocketReviewCompleteness,
157    WebSocketReviewExecutionLimits, WebSocketReviewFailureKind, WebSocketReviewMessageAudit,
158    WebSocketReviewMessageStatus, WebSocketReviewTerminal,
159    MAX_WEBSOCKET_REVIEW_APPLICATION_BYTES_PER_DIRECTION, MAX_WEBSOCKET_REVIEW_CONNECTIONS,
160    WEBSOCKET_REVIEW_ACTION_ID, WEBSOCKET_REVIEW_AUDIT_SCHEMA, WEBSOCKET_REVIEW_AUTHENTICATION,
161    WEBSOCKET_REVIEW_CAPABILITY_ID, WEBSOCKET_REVIEW_HTTP2_EXTENDED_CONNECT,
162    WEBSOCKET_REVIEW_TRANSPORT, WEBSOCKET_REVIEW_TRANSPORT_BYTE_SCOPE,
163};
164
165#[cfg(feature = "reporting")]
166pub use assessment_report::{
167    AssessmentRunReport, AssessmentRunReportError, ASSESSMENT_RUN_REPORT_SCHEMA,
168    MAX_ASSESSMENT_RUN_ITEMS,
169};
170
171#[cfg(feature = "supplied-session-review")]
172pub use crate::supplied_session_review::{
173    MAX_SUPPLIED_SESSION_CHECKPOINTS, MAX_SUPPLIED_SESSION_REQUESTS, MAX_SUPPLIED_SESSION_RESOURCES,
174};
175#[cfg(feature = "xml-external-entity-review")]
176pub use crate::xml_external_entity_review::{
177    XmlExternalEntityExecutionMode, XmlExternalEntityReviewAudit, XmlExternalEntityReviewOutcome,
178    XML_EXTERNAL_ENTITY_ACTIVE_VERIFICATIONS, XML_EXTERNAL_ENTITY_REVIEW_ACTION_ID,
179    XML_EXTERNAL_ENTITY_REVIEW_AUDIT_SCHEMA, XML_EXTERNAL_ENTITY_REVIEW_CAPABILITY_ID,
180    XML_EXTERNAL_ENTITY_TARGET_REQUESTS,
181};
182#[cfg(feature = "graphql-review")]
183pub(crate) use graphql_runtime::GraphqlResponsePrivacy;
184#[cfg(feature = "openapi-review")]
185pub use openapi_runtime::{
186    OpenApiCandidateSource, OpenApiRuntimeOutcome, WebAssessmentOpenApiAudit,
187    MAX_OPENAPI_REVIEW_ACTIVE_VERIFICATIONS, MAX_OPENAPI_REVIEW_DOCUMENTS,
188    MAX_OPENAPI_REVIEW_REQUESTS, OPENAPI_REVIEW_ACTION_ID, OPENAPI_REVIEW_CAPABILITY_ID,
189};
190#[cfg(feature = "recon-ct-provider")]
191pub use recon_ct_runtime::{
192    recon_ct_provider_scope_is_valid, ReconCtProviderFailureKind, WebAssessmentReconCtProviderAudit,
193};
194#[cfg(feature = "authorization-review")]
195pub use resource_authorization_runtime::{
196    WebAssessmentAuthorizationAudit, MAX_AUTHORIZATION_REVIEW_ACTIVE_VERIFICATIONS,
197    MAX_AUTHORIZATION_REVIEW_REQUESTS, MAX_AUTHORIZATION_REVIEW_RESOURCES,
198    RESOURCE_AUTHORIZATION_REVIEW_ACTION_ID, RESOURCE_AUTHORIZATION_REVIEW_CAPABILITY_ID,
199};
200#[cfg(feature = "rest-review")]
201pub use rest_runtime::{
202    RestObservedMediaClass, RestRuntimeOutcome, WebAssessmentRestAudit,
203    MAX_REST_REVIEW_ACTIVE_VERIFICATIONS, MAX_REST_REVIEW_REQUESTS, MAX_REST_REVIEW_RESOURCES,
204    REST_REVIEW_ACTION_ID, REST_REVIEW_CAPABILITY_ID,
205};
206#[cfg(feature = "scanning")]
207pub use scan_profile::{
208    BuiltInScanProfile, BuiltInScanProfileParseError, ScanProfileCapabilitiesV1,
209    ScanProfileLimitsV1, ScanProfileScope, ScanProfileSelectionError, ScanProfileV1,
210    ScanProfileV1Error, BASELINE_SCAN_PROFILE_ID, BASELINE_SCAN_PROFILE_MAX_TOTAL_REQUESTS,
211    BASELINE_SCAN_PROFILE_MAX_TOTAL_RESPONSE_BYTES, BASELINE_SCAN_PROFILE_MAX_WALL_TIME_MS,
212    SCAN_PROFILE_V1_SCHEMA, WEB_REVIEW_SCAN_PROFILE_ID,
213};
214#[cfg(all(feature = "graphql-review", feature = "secret-exposure-review"))]
215pub(crate) use secret_exposure::project_graphql_body_privacy_lineage;
216#[cfg(feature = "secret-exposure-review")]
217pub use secret_exposure::{
218    SecretExposureDetectorClass, SecretExposureResponseOutcome, WebAssessmentSecretExposureAudit,
219    MAX_SECRET_EXPOSURE_BODY_BYTES, MAX_SECRET_EXPOSURE_OCCURRENCES, MAX_SECRET_EXPOSURE_RESPONSES,
220    MAX_SECRET_EXPOSURE_RETAINED_OBSERVATIONS, MAX_SECRET_EXPOSURE_TOTAL_BODY_BYTES,
221    SECRET_EXPOSURE_AUDIT_SCHEMA, SECRET_EXPOSURE_CATALOGUE_ID, SECRET_EXPOSURE_CATALOGUE_REVISION,
222    SECRET_EXPOSURE_POLICY_ID, SECRET_EXPOSURE_REPRESENTATION,
223};
224#[cfg(feature = "ssrf-oast-review")]
225pub use ssrf_oast_runtime::{
226    SsrfOastProviderFailure, SsrfOastRuntimeOutcome, WebAssessmentSsrfOastAudit,
227    MAX_SSRF_OAST_REVIEW_ACTIVE_VERIFICATIONS, MAX_SSRF_OAST_REVIEW_PARAMETERS,
228    MAX_SSRF_OAST_REVIEW_PROVIDER_REQUESTS, MAX_SSRF_OAST_REVIEW_REQUESTS,
229    MAX_SSRF_OAST_REVIEW_RESOURCES, SSRF_OAST_REVIEW_ACTION_ID, SSRF_OAST_REVIEW_CAPABILITY_ID,
230};
231#[cfg(feature = "supplied-session-review")]
232pub use supplied_session_runtime::{
233    SuppliedSessionAuditOutcome, SuppliedSessionBodyState, SuppliedSessionCookieLifecycleAudit,
234    SuppliedSessionCookiePolicyAudit, SuppliedSessionCoverage,
235    SuppliedSessionHealthCheckpointPhase, SuppliedSessionHealthOracleAudit,
236    SuppliedSessionHealthOracleKind, SuppliedSessionHealthOutcome, SuppliedSessionLoginFormOutcome,
237    SuppliedSessionLoginSubmitOutcome, SuppliedSessionOAuthAuthorizationAudit,
238    SuppliedSessionOAuthAuthorizationOutcome, SuppliedSessionOAuthCodeAudit,
239    SuppliedSessionOAuthCodeOutcome, SuppliedSessionOAuthInitiatedTokenAudit,
240    SuppliedSessionOAuthRefreshAudit, SuppliedSessionOAuthRefreshOutcome,
241    SuppliedSessionPredicateOutcome, SuppliedSessionPrincipalAssurance,
242    SuppliedSessionResourceOutcome, WebAssessmentSuppliedSessionAudit,
243    WebAssessmentSuppliedSessionCheckpointAudit, WebAssessmentSuppliedSessionResourceAudit,
244    SUPPLIED_SESSION_AUDIT_SCHEMA, SUPPLIED_SESSION_CAPABILITY_ID,
245    SUPPLIED_SESSION_COOKIE_AUDIT_SCHEMA, SUPPLIED_SESSION_FORM_LOGIN_AUDIT_SCHEMA,
246    SUPPLIED_SESSION_OAUTH_AUTHORIZATION_AUDIT_SCHEMA, SUPPLIED_SESSION_OAUTH_CODE_AUDIT_SCHEMA,
247    SUPPLIED_SESSION_OAUTH_REFRESH_AUDIT_SCHEMA, SUPPLIED_SESSION_RENEWAL_AUDIT_SCHEMA,
248};
249#[cfg(feature = "tls-observation")]
250pub use tls_observation::{
251    TlsCertificateTimeStatus, TlsLeafObservation, TlsObservationTargetScheme,
252    WebAssessmentTlsObservationAudit, MAX_TLS_CERTIFICATE_EXTENSIONS,
253    MAX_TLS_LEAF_CERTIFICATE_BYTES, MAX_TLS_RETAINED_LEAF_OBSERVATIONS,
254    MAX_TLS_SAN_ENTRIES_PER_CERTIFICATE, MAX_TLS_SUBJECT_ALTERNATIVE_NAME_BYTES,
255    TLS_OBSERVATION_AUDIT_SCHEMA, TLS_OBSERVATION_BACKEND_LIMIT, TLS_OBSERVATION_CLOCK_ASSURANCE,
256    TLS_OBSERVATION_POLICY_ID, TLS_OBSERVATION_REVOCATION_STATUS, TLS_OBSERVATION_SOURCE_SCOPE,
257    TLS_OBSERVATION_VALIDATION_SCOPE,
258};
259pub use web_assessment::{
260    WebAssessmentCompletion, WebAssessmentDefenseAudit, WebAssessmentDefenseBodyCoverage,
261    WebAssessmentDefenseMode, WebAssessmentDefenseObservation, WebAssessmentDefenseShadowPlan,
262    WebAssessmentDefenseTransition, WebAssessmentFailureReceipt, WebAssessmentForm,
263    WebAssessmentFormMethod, WebAssessmentIncompleteReason, WebAssessmentLimits,
264    WebAssessmentLimitsError, WebAssessmentMethod, WebAssessmentProgressCheckpoint,
265    WebAssessmentProgressObserver, WebAssessmentProgressSnapshot, WebAssessmentRunReport,
266    WebAssessmentRuntime, WebAssessmentRuntimeBuilder, WebAssessmentRuntimeError,
267    WebAssessmentSubject, WebAssessmentSubjectOrigin, WebAssessmentSubjectReport,
268    WebAssessmentUsage, DEFAULT_WEB_ASSESSMENT_MAX_ACTIVE_VERIFICATIONS,
269    DEFAULT_WEB_ASSESSMENT_MAX_CANONICAL_URL_BYTES, DEFAULT_WEB_ASSESSMENT_MAX_CONTROLS_PER_FORM,
270    DEFAULT_WEB_ASSESSMENT_MAX_DEPTH, DEFAULT_WEB_ASSESSMENT_MAX_FORMS,
271    DEFAULT_WEB_ASSESSMENT_MAX_QUERY_NAMES, DEFAULT_WEB_ASSESSMENT_MAX_REFERENCES_PER_DOCUMENT,
272    DEFAULT_WEB_ASSESSMENT_MAX_RESPONSE_BODY_BYTES, DEFAULT_WEB_ASSESSMENT_MAX_RETAINED_URL_BYTES,
273    DEFAULT_WEB_ASSESSMENT_MAX_SUBJECTS, DEFAULT_WEB_ASSESSMENT_MAX_TOTAL_REQUESTS,
274    DEFAULT_WEB_ASSESSMENT_MAX_TOTAL_RESPONSE_BYTES, DEFAULT_WEB_ASSESSMENT_MAX_WALL_TIME,
275    HARD_MAX_WEB_ASSESSMENT_ACTIVE_VERIFICATIONS, HARD_MAX_WEB_ASSESSMENT_CANONICAL_URL_BYTES,
276    HARD_MAX_WEB_ASSESSMENT_CONTROLS_PER_FORM, HARD_MAX_WEB_ASSESSMENT_DEPTH,
277    HARD_MAX_WEB_ASSESSMENT_FORMS, HARD_MAX_WEB_ASSESSMENT_QUERY_NAMES,
278    HARD_MAX_WEB_ASSESSMENT_REFERENCES_PER_DOCUMENT, HARD_MAX_WEB_ASSESSMENT_RESPONSE_BODY_BYTES,
279    HARD_MAX_WEB_ASSESSMENT_RETAINED_URL_BYTES, HARD_MAX_WEB_ASSESSMENT_SUBJECTS,
280    HARD_MAX_WEB_ASSESSMENT_TOTAL_REQUESTS, HARD_MAX_WEB_ASSESSMENT_TOTAL_RESPONSE_BYTES,
281    HARD_MAX_WEB_ASSESSMENT_WALL_TIME, WEB_ASSESSMENT_CONCURRENCY,
282};
283#[cfg(feature = "wordpress-review")]
284pub use wordpress_runtime::{
285    WebAssessmentWordPressAudit, WebAssessmentWordPressDiscoveryAudit,
286    WordPressAssetFingerprintAcquisition, WordPressAssetFingerprintExecution,
287    WordPressAssetFingerprintResourceOutcome, WordPressAssetFingerprintResourceReceipt,
288    WordPressDiscoverySourceAudit, WordPressDiscoverySourceKind, WordPressDiscoverySourceOutcome,
289    WordPressPageAcquisition, WordPressPageAssociation, WordPressPageAudit, WordPressPageOutcome,
290    WordPressPageScope, WordPressPageScopeAudit, WordPressPluginDiscoveryMetadata,
291    WordPressThemeDiscoveryMetadata, WORDPRESS_DISCOVERY_OBSERVATION_CAPABILITY_ID,
292    WORDPRESS_REVIEW_CAPABILITY_ID,
293};
294
295/// Fuzz-only friend seam for the exact WordPress discovery parsers and
296/// candidate-admission rules. This symbol does not exist in normal builds.
297#[cfg(all(fuzzing, feature = "wordpress-review"))]
298#[doc(hidden)]
299pub fn check_wordpress_discovery_fuzz_input(data: &[u8]) {
300    const MAX_FUZZ_INPUT_BYTES: usize = 512 * 1024;
301    if data.len() > MAX_FUZZ_INPUT_BYTES {
302        return;
303    }
304    let (scenario, payload) = data
305        .split_first()
306        .map_or((0, data), |(tag, rest)| (*tag, rest));
307    wordpress_discovery::fuzz_check_wordpress_discovery_parser(scenario, payload);
308    let origin =
309        Url::parse("https://wordpress-fuzz.invalid/").expect("fixed fuzz origin must remain valid");
310    let header_payload = if scenario % 8 == 1 {
311        payload
312            .strip_suffix(b"\n")
313            .map(|value| value.strip_suffix(b"\r").unwrap_or(value))
314            .unwrap_or(payload)
315    } else {
316        payload
317    };
318    let rest =
319        crate::http_evidence::fuzz_wordpress_rest_index_advertisement(&origin, header_payload);
320    wordpress_runtime::fuzz_check_discovery_candidate_admission(payload, &rest);
321}
322
323const DEFAULT_BUSINESS_VALUE_PERCENT: u8 = 80;
324const DEFAULT_PLANNING_BUDGET: u64 = 100;
325const DEFAULT_RISK_LIMIT_PERCENT: u8 = 40;
326const DEFAULT_MAX_ACTION_CYCLES: u32 = 8;
327const DEFAULT_FAILURE_LIMIT: u16 = 10;
328pub(crate) const BOOTSTRAP_ACTION_ID: &str = "web.action.bootstrap.http-evidence";
329pub(crate) const BOOTSTRAP_CASE_ID: &str = "case:web-runtime:bootstrap:http";
330pub(crate) const BOOTSTRAP_HYPOTHESIS_ID: &str = "hypothesis:web-runtime:bootstrap";
331/// Construction and execution failures for [`StandardWebDecisionRuntime`].
332#[derive(Debug, Error)]
333#[non_exhaustive]
334pub enum StandardWebDecisionRuntimeError {
335    /// A runtime instance was asked to execute its single-use session twice.
336    #[error("standard web decision runtime has already started")]
337    AlreadyStarted,
338
339    /// A planner score or action policy was invalid.
340    #[error(transparent)]
341    Planner(#[from] PlannerError),
342
343    /// Decision-loop configuration or state transition failed.
344    #[error(transparent)]
345    Decision(#[from] DecisionLoopError),
346
347    /// Experience suppression policy was invalid.
348    #[error(transparent)]
349    Experience(#[from] ExperienceStoreError),
350
351    /// A target-scoped reasoning identity was invalid.
352    #[error(transparent)]
353    Reasoning(#[from] ReasoningModelError),
354
355    /// A bootstrap verification identity was invalid.
356    #[error(transparent)]
357    Verification(#[from] VerificationError),
358
359    /// HTTP scope, resource, or collector construction failed.
360    #[error(transparent)]
361    Http(#[from] HttpEvidenceError),
362
363    /// The standard reasoning, planning, execution, or verification profile failed.
364    #[error(transparent)]
365    Profile(#[from] StandardWebDecisionError),
366
367    /// The optional JSON response-format and GraphQL surface profile failed to install.
368    #[error(transparent)]
369    ApiReasoning(#[from] StandardApiReasoningError),
370
371    /// The closed native review reasoning/action/verifier catalog failed validation.
372    #[error("native web-review decision profile could not be composed")]
373    NativeWebReviewDecisionProfile,
374
375    /// The closed native review executor and payload catalog failed validation.
376    #[error("native web-review execution profile could not be composed")]
377    NativeWebReviewExecutionProfile,
378
379    /// The bounded authorization action/executor could not join the parent runtime.
380    #[cfg(feature = "authorization-review")]
381    #[error("resource authorization review could not be composed")]
382    ResourceAuthorizationReviewComposition,
383
384    /// An executor lookup, request, evidence commit, or runner transition failed.
385    #[error(transparent)]
386    Runner(#[from] DecisionRunnerError),
387
388    /// Standard HTTP execution omitted or duplicated its resource telemetry.
389    #[error(
390        "execution case {case_id} emitted {observations} unsigned {predicate} observations; expected exactly one"
391    )]
392    ResponseUsageEvidence {
393        /// Execution case whose correlated evidence was invalid.
394        case_id: String,
395        /// Stable response-body usage predicate.
396        predicate: &'static str,
397        /// Matching unsigned observations found in the committed snapshot.
398        observations: usize,
399        /// Durable evidence commit that exposed the telemetry violation.
400        receipt: Box<DecisionEvidenceReceipt>,
401    },
402
403    /// Committed assessment-defense evidence failed its closed replay schema.
404    #[error("committed assessment defense projection violated its closed schema")]
405    AssessmentDefenseProjectionInvariant {
406        /// Durable evidence commit rejected by the assessment replay boundary.
407        receipt: Box<DecisionEvidenceReceipt>,
408    },
409
410    /// Assessment defense policy could not classify the exact authorized plan.
411    #[error("assessment defense planning authority invariant failed")]
412    AssessmentDefensePlanningInvariant,
413
414    /// A non-execution command reached the transport-accounting boundary.
415    #[error("runtime resource accounting requires an execution command")]
416    ExecutionMetadataUnavailable,
417
418    /// Execution failed after the single-use runtime had started.
419    ///
420    /// The receipt preserves every earlier completed turn and the resource
421    /// accounting snapshot observed at the failure boundary. The nested source
422    /// retains any current execution, evidence, or reasoning receipt.
423    #[error("standard web decision runtime failed after it started: {source}")]
424    RunFailed {
425        /// Completed audit history and final resource usage before the error.
426        receipt: Box<StandardWebDecisionFailureReceipt>,
427        /// Typed failure raised at the current runtime boundary.
428        #[source]
429        source: Box<StandardWebDecisionRuntimeError>,
430    },
431}
432
433impl StandardWebDecisionRuntimeError {
434    /// Returns completed audit history captured when a started run failed.
435    pub fn failure_receipt(&self) -> Option<&StandardWebDecisionFailureReceipt> {
436        match self {
437            Self::RunFailed { receipt, .. } => Some(receipt),
438            _ => None,
439        }
440    }
441
442    /// Removes the subject-local audit from a started failure without carrying
443    /// its cumulative authority snapshots into an outer assessment receipt.
444    pub(crate) fn into_assessment_failure(
445        self,
446    ) -> (
447        StandardWebDecisionAssessmentFailureParts,
448        StandardWebDecisionRuntimeError,
449    ) {
450        match self {
451            Self::RunFailed { receipt, source } => (receipt.into_assessment_parts(), *source),
452            source => (StandardWebDecisionAssessmentFailureParts::default(), source),
453        }
454    }
455
456    /// Takes completed audit history captured when a started run failed.
457    pub fn into_failure_receipt(self) -> Option<StandardWebDecisionFailureReceipt> {
458        match self {
459            Self::RunFailed { receipt, .. } => Some(*receipt),
460            _ => None,
461        }
462    }
463
464    /// Returns an executor-reported pre-commit failure receipt, when applicable.
465    pub fn execution_failure(&self) -> Option<&DecisionExecutionFailureReceipt> {
466        match self {
467            Self::Runner(source) => source.execution_failure(),
468            Self::RunFailed { source, .. } => source.execution_failure(),
469            _ => None,
470        }
471    }
472
473    /// Takes ownership of an executor-reported failure receipt without cloning it.
474    pub fn into_execution_failure(self) -> Option<DecisionExecutionFailureReceipt> {
475        match self {
476            Self::Runner(source) => source.into_execution_failure(),
477            Self::RunFailed { source, .. } => source.into_execution_failure(),
478            _ => None,
479        }
480    }
481
482    /// Returns evidence committed before this runtime error, when applicable.
483    pub fn committed_evidence(&self) -> Option<&DecisionEvidenceReceipt> {
484        match self {
485            Self::Runner(source) => source.committed_evidence(),
486            Self::ResponseUsageEvidence { receipt, .. } => Some(receipt),
487            Self::AssessmentDefenseProjectionInvariant { receipt } => Some(receipt),
488            Self::RunFailed { source, .. } => source.committed_evidence(),
489            _ => None,
490        }
491    }
492
493    /// Takes ownership of evidence committed before this error without cloning it.
494    pub fn into_committed_evidence(self) -> Option<DecisionEvidenceReceipt> {
495        match self {
496            Self::Runner(source) => source.into_committed_evidence(),
497            Self::ResponseUsageEvidence { receipt, .. } => Some(*receipt),
498            Self::AssessmentDefenseProjectionInvariant { receipt } => Some(*receipt),
499            Self::RunFailed { source, .. } => source.into_committed_evidence(),
500            _ => None,
501        }
502    }
503
504    /// Returns reasoning committed before a later planning failure, when applicable.
505    pub fn committed_reasoning(&self) -> Option<&DecisionReasoningCommitReceipt> {
506        match self {
507            Self::Decision(source) => source.committed_reasoning(),
508            Self::Runner(source) => source.committed_reasoning(),
509            Self::RunFailed { source, .. } => source.committed_reasoning(),
510            _ => None,
511        }
512    }
513
514    /// Takes a post-reasoning planning receipt without cloning it.
515    pub fn into_committed_reasoning(self) -> Option<DecisionReasoningCommitReceipt> {
516        match self {
517            Self::Decision(source) => source.into_committed_reasoning(),
518            Self::Runner(source) => source.into_committed_reasoning(),
519            Self::RunFailed { source, .. } => source.into_committed_reasoning(),
520            _ => None,
521        }
522    }
523}
524
525/// One non-terminal audit record produced while driving a runtime session.
526#[derive(Debug)]
527#[non_exhaustive]
528pub enum StandardWebDecisionRuntimeTurn {
529    /// Reasoning and utility planning selected the next command.
530    Planning(Box<DecisionPlanningReport>),
531    /// An executor committed evidence and the verifier classified the case.
532    Outcome {
533        /// Provenance-validated evidence commit receipt.
534        evidence: Box<DecisionEvidenceReceipt>,
535        /// Verification, adaptation, experience, and next-command report.
536        decision: Box<DecisionOutcomeReport>,
537    },
538}
539
540/// Completed audit history retained when a started runtime returns an error.
541///
542/// This process-local receipt covers work completed before the failing
543/// boundary. Cause-specific receipts for the current boundary remain available
544/// through [`StandardWebDecisionRuntimeError`] accessors.
545#[derive(Debug)]
546pub struct StandardWebDecisionFailureReceipt {
547    bootstrap: Option<DecisionEvidenceReceipt>,
548    completed_turns: Vec<StandardWebDecisionRuntimeTurn>,
549    usage: RuntimeUsage,
550    transport: TransportDispatchAudit,
551}
552
553impl StandardWebDecisionFailureReceipt {
554    /// Returns bootstrap evidence committed before the later failure.
555    pub fn bootstrap(&self) -> Option<&DecisionEvidenceReceipt> {
556        self.bootstrap.as_ref()
557    }
558
559    /// Returns planning and outcome turns completed before the later failure.
560    pub fn completed_turns(&self) -> &[StandardWebDecisionRuntimeTurn] {
561        &self.completed_turns
562    }
563
564    /// Returns resource accounting observed at the failure boundary.
565    pub fn usage(&self) -> &RuntimeUsage {
566        &self.usage
567    }
568
569    /// Returns bounded per-dispatch transport receipts at the failure boundary.
570    pub fn transport(&self) -> &TransportDispatchAudit {
571        &self.transport
572    }
573
574    fn into_assessment_parts(self: Box<Self>) -> StandardWebDecisionAssessmentFailureParts {
575        let Self {
576            bootstrap,
577            completed_turns,
578            usage: _,
579            transport: _,
580        } = *self;
581        StandardWebDecisionAssessmentFailureParts {
582            bootstrap,
583            turns: completed_turns,
584        }
585    }
586}
587
588/// Complete audit trail from bootstrap evidence to a terminal command.
589#[derive(Debug)]
590pub struct StandardWebDecisionRunReport {
591    bootstrap: Option<DecisionEvidenceReceipt>,
592    turns: Vec<StandardWebDecisionRuntimeTurn>,
593    unverified_evidence: Option<DecisionEvidenceReceipt>,
594    terminal: DecisionLoopCommand,
595    usage: RuntimeUsage,
596    transport: TransportDispatchAudit,
597    limit_exceeded: Option<RuntimeLimitExceeded>,
598    execution_failure: Option<DecisionExecutionFailureReceipt>,
599}
600
601/// Standard-run audit parts retained by one origin-assessment subject.
602///
603/// Usage and transport are intentionally absent. Every assessment subject uses
604/// one shared authority, so only the outer assessment report may expose those
605/// cumulative records.
606pub(crate) struct StandardWebDecisionAssessmentParts {
607    pub(crate) bootstrap: Option<DecisionEvidenceReceipt>,
608    pub(crate) turns: Vec<StandardWebDecisionRuntimeTurn>,
609    pub(crate) unverified_evidence: Option<DecisionEvidenceReceipt>,
610    pub(crate) terminal: DecisionLoopCommand,
611    pub(crate) limit_exceeded: Option<RuntimeLimitExceeded>,
612    pub(crate) execution_failure: Option<DecisionExecutionFailureReceipt>,
613}
614
615/// Subject-local work preserved from a failed Standard runtime.
616///
617/// The global usage and transport snapshots are intentionally discarded; the
618/// host assessment owns exactly one cumulative authority audit.
619#[derive(Default)]
620pub(crate) struct StandardWebDecisionAssessmentFailureParts {
621    pub(crate) bootstrap: Option<DecisionEvidenceReceipt>,
622    pub(crate) turns: Vec<StandardWebDecisionRuntimeTurn>,
623}
624
625impl StandardWebDecisionRunReport {
626    /// Returns the initial GET evidence committed before reasoning starts.
627    pub fn bootstrap(&self) -> Option<&DecisionEvidenceReceipt> {
628        self.bootstrap.as_ref()
629    }
630
631    /// Returns non-terminal planning and outcome turns in execution order.
632    pub fn turns(&self) -> &[StandardWebDecisionRuntimeTurn] {
633        &self.turns
634    }
635
636    /// Returns evidence durably committed before verification was skipped.
637    ///
638    /// This is populated when execution committed its evidence batch before
639    /// host cancellation or a response-byte threshold crossing halted the
640    /// turn. The receipt stays outside [`Self::outcome_reports`] because no
641    /// verifier outcome exists for this batch.
642    pub fn unverified_evidence(&self) -> Option<&DecisionEvidenceReceipt> {
643        self.unverified_evidence.as_ref()
644    }
645
646    /// Returns the command that ended the session.
647    pub fn terminal(&self) -> &DecisionLoopCommand {
648        &self.terminal
649    }
650
651    /// Returns the final resource accounting snapshot.
652    pub fn usage(&self) -> &RuntimeUsage {
653        &self.usage
654    }
655
656    /// Returns bounded, dispatch-ordered transport receipts for this run.
657    pub fn transport(&self) -> &TransportDispatchAudit {
658        &self.transport
659    }
660
661    /// Returns the structured runtime limit when the resource envelope stopped execution.
662    pub fn limit_exceeded(&self) -> Option<&RuntimeLimitExceeded> {
663        self.limit_exceeded.as_ref()
664    }
665
666    /// Returns the transport execution receipt when a broker-owned resource
667    /// limit refused a dispatch after the semantic action had started.
668    pub fn execution_failure(&self) -> Option<&DecisionExecutionFailureReceipt> {
669        self.execution_failure.as_ref()
670    }
671
672    /// Iterates over planning audit reports in turn order.
673    pub fn planning_reports(&self) -> impl Iterator<Item = &DecisionPlanningReport> {
674        self.turns.iter().filter_map(|turn| match turn {
675            StandardWebDecisionRuntimeTurn::Planning(report) => Some(report.as_ref()),
676            StandardWebDecisionRuntimeTurn::Outcome { .. } => None,
677        })
678    }
679
680    /// Iterates over verified outcome reports in turn order.
681    pub fn outcome_reports(&self) -> impl Iterator<Item = &DecisionOutcomeReport> {
682        self.turns.iter().filter_map(|turn| match turn {
683            StandardWebDecisionRuntimeTurn::Outcome { decision, .. } => Some(decision.as_ref()),
684            StandardWebDecisionRuntimeTurn::Planning(_) => None,
685        })
686    }
687
688    pub(crate) fn into_assessment_parts(self) -> StandardWebDecisionAssessmentParts {
689        StandardWebDecisionAssessmentParts {
690            bootstrap: self.bootstrap,
691            turns: self.turns,
692            unverified_evidence: self.unverified_evidence,
693            terminal: self.terminal,
694            limit_exceeded: self.limit_exceeded,
695            execution_failure: self.execution_failure,
696        }
697    }
698}
699
700/// Builder for one target-scoped [`StandardWebDecisionRuntime`].
701pub struct StandardWebDecisionRuntimeBuilder {
702    target: Url,
703    http_policy: Option<HttpEvidencePolicy>,
704    business_value_percent: u8,
705    planning_budget: u64,
706    risk_limit_percent: u8,
707    adaptation_limits: AdaptationLimits,
708    experience_failure_limit: u16,
709    max_action_cycles: u32,
710    experience: ExperienceStore,
711    runtime_budget: RuntimeBudget,
712    api_reasoning_enabled: bool,
713    payload_binding: Option<HttpHeaderPayloadBinding>,
714    cancellation: CancellationToken,
715    bootstrap_probe_method: HttpProbeMethod,
716    complete_response_observer: Option<Arc<dyn CompleteHttpResponseObserver>>,
717    additional_suppressed_actions: BTreeSet<String>,
718    assessment_defense_projection: bool,
719    assessment_defense_enforcement: bool,
720    native_web_review: Option<NativeWebReviewRuntimeConfig>,
721    #[cfg(feature = "authorization-review")]
722    resource_authorization_review:
723        Option<resource_authorization_runtime::ResourceAuthorizationReviewConfig>,
724    #[cfg(feature = "openapi-review")]
725    openapi_review: Option<openapi_runtime::OpenApiReviewConfig>,
726    #[cfg(feature = "rest-review")]
727    rest_review: bool,
728    #[cfg(feature = "ssrf-oast-review")]
729    ssrf_oast_review: Option<ssrf_oast_runtime::SsrfOastReviewConfig>,
730}
731
732struct NativeWebReviewRuntimeConfig {
733    seeds: NativeWebReviewSeeds,
734    observer: Arc<dyn CompleteHttpResponseObserver>,
735    redirect_query_parameter: Option<String>,
736    reflection_query_parameter: Option<String>,
737    sql_query_parameter: Option<String>,
738    ssti_query_parameter: Option<String>,
739    #[cfg(feature = "template-evaluation-review")]
740    template_evaluation_query_parameter: Option<String>,
741    xss_query_parameter: Option<String>,
742    xss_selection: Option<web_assessment::XssProbeSelection>,
743    #[cfg(feature = "normalization-resilience")]
744    normalization_query_parameter: Option<String>,
745    #[cfg(feature = "normalization-resilience")]
746    normalization_selection: Option<web_assessment::NormalizationTransformSelection>,
747    structural_only: bool,
748}
749
750struct StandardWebDecisionRuntimePreflight {
751    config: DecisionLoopConfig,
752    subject: EntityId,
753}
754
755impl StandardWebDecisionRuntimeBuilder {
756    /// Creates a builder with conservative deterministic defaults.
757    pub fn new(target: Url) -> Self {
758        Self {
759            target,
760            http_policy: None,
761            business_value_percent: DEFAULT_BUSINESS_VALUE_PERCENT,
762            planning_budget: DEFAULT_PLANNING_BUDGET,
763            risk_limit_percent: DEFAULT_RISK_LIMIT_PERCENT,
764            adaptation_limits: AdaptationLimits::default(),
765            experience_failure_limit: DEFAULT_FAILURE_LIMIT,
766            max_action_cycles: DEFAULT_MAX_ACTION_CYCLES,
767            experience: ExperienceStore::new(),
768            runtime_budget: RuntimeBudget::default(),
769            api_reasoning_enabled: false,
770            payload_binding: None,
771            cancellation: CancellationToken::new(),
772            bootstrap_probe_method: HttpProbeMethod::Get,
773            complete_response_observer: None,
774            additional_suppressed_actions: BTreeSet::new(),
775            assessment_defense_projection: false,
776            assessment_defense_enforcement: false,
777            native_web_review: None,
778            #[cfg(feature = "authorization-review")]
779            resource_authorization_review: None,
780            #[cfg(feature = "openapi-review")]
781            openapi_review: None,
782            #[cfg(feature = "rest-review")]
783            rest_review: false,
784            #[cfg(feature = "ssrf-oast-review")]
785            ssrf_oast_review: None,
786        }
787    }
788
789    /// Enables passive JSON response-format and GraphQL surface reasoning.
790    ///
791    /// This opt-in reuses evidence already collected by the runtime. It adds no
792    /// request, executor, payload, visibility comparison, or planner action.
793    pub fn enable_api_reasoning(mut self) -> Self {
794        self.api_reasoning_enabled = true;
795        self
796    }
797
798    /// Replaces the default single-origin HTTP evidence policy.
799    pub fn http_policy(mut self, policy: HttpEvidencePolicy) -> Self {
800        self.http_policy = Some(policy);
801        self
802    }
803
804    /// Binds a header-valued payload strategy to the runtime's HTTP evidence
805    /// executor.
806    ///
807    /// The bound executor shares the runtime's metered request broker, so any
808    /// control or candidate artifact it derives and dispatches is accounted like
809    /// every other request. This is strictly opt-in: without a binding the
810    /// runtime materializes and dispatches no payload artifacts.
811    pub fn with_payload_binding(mut self, binding: HttpHeaderPayloadBinding) -> Self {
812        self.payload_binding = Some(binding);
813        self
814    }
815
816    /// Sets target business value as an integer percentage.
817    pub fn business_value(mut self, percent: u8) -> Self {
818        self.business_value_percent = percent;
819        self
820    }
821
822    /// Sets the planner's total action-cost budget.
823    pub fn planning_budget(mut self, budget: u64) -> Self {
824        self.planning_budget = budget;
825        self
826    }
827
828    /// Sets the maximum accepted action risk as an integer percentage.
829    pub fn risk_limit(mut self, percent: u8) -> Self {
830        self.risk_limit_percent = percent;
831        self
832    }
833
834    /// Replaces the adaptive transition limits.
835    pub fn adaptation_limits(mut self, limits: AdaptationLimits) -> Self {
836        self.adaptation_limits = limits;
837        self
838    }
839
840    /// Sets the consecutive completed-failure suppression threshold.
841    pub fn experience_failure_limit(mut self, limit: u16) -> Self {
842        self.experience_failure_limit = limit;
843        self
844    }
845
846    /// Sets the maximum number of passive action executions in one session.
847    pub fn max_action_cycles(mut self, cycles: u32) -> Self {
848        self.max_action_cycles = cycles;
849        self
850    }
851
852    /// Seeds the runtime with experience retained by the host.
853    pub fn experience_store(mut self, experience: ExperienceStore) -> Self {
854        self.experience = experience;
855        self
856    }
857
858    /// Replaces the complete runtime resource envelope.
859    pub fn runtime_budget(mut self, budget: RuntimeBudget) -> Self {
860        self.runtime_budget = budget;
861        self
862    }
863
864    /// Replaces the host-owned cancellation token for this runtime.
865    ///
866    /// Cancellation is reported independently from wall-time and transport
867    /// request timeouts. The host should retain a clone when it needs to stop
868    /// [`StandardWebDecisionRuntime::analyze`] from another task.
869    pub fn cancellation_token(mut self, cancellation: CancellationToken) -> Self {
870        self.cancellation = cancellation;
871        self
872    }
873
874    /// Installs the sealed assessment projection on the bootstrap request.
875    ///
876    /// HEAD subjects are metadata observations only: all post-bootstrap
877    /// semantic actions are suppressed so they cannot silently become GET or
878    /// OPTIONS work. GET subjects retain the standard decision behavior.
879    pub(crate) fn with_assessment_response_observer(
880        mut self,
881        method: HttpProbeMethod,
882        observer: Arc<dyn CompleteHttpResponseObserver>,
883    ) -> Self {
884        self.bootstrap_probe_method = method;
885        self.complete_response_observer = Some(observer);
886        self.assessment_defense_projection = true;
887        if method == HttpProbeMethod::Head {
888            self.additional_suppressed_actions.extend(
889                StandardWebActionKind::all()
890                    .into_iter()
891                    .map(|kind| kind.action_id().to_owned()),
892            );
893        }
894        self
895    }
896
897    pub(crate) fn with_assessment_defense_enforcement(mut self, enabled: bool) -> Self {
898        self.assessment_defense_projection = true;
899        self.assessment_defense_enforcement = enabled;
900        self
901    }
902
903    /// Installs the one resource-authorization action into this parent subject
904    /// runtime. The config remains move-only and no secondary runner is built.
905    #[cfg(feature = "authorization-review")]
906    pub(in crate::web_runtime) fn with_resource_authorization_review(
907        mut self,
908        config: resource_authorization_runtime::ResourceAuthorizationReviewConfig,
909    ) -> Self {
910        self.assessment_defense_projection = true;
911        self.resource_authorization_review = Some(config);
912        self
913    }
914
915    #[cfg(feature = "openapi-review")]
916    pub(in crate::web_runtime) fn with_openapi_review(
917        mut self,
918        config: openapi_runtime::OpenApiReviewConfig,
919    ) -> Self {
920        self.assessment_defense_projection = true;
921        self.openapi_review = Some(config);
922        self
923    }
924
925    #[cfg(feature = "rest-review")]
926    pub(in crate::web_runtime) fn with_rest_review(mut self) -> Self {
927        self.assessment_defense_projection = true;
928        self.rest_review = true;
929        self
930    }
931
932    #[cfg(feature = "ssrf-oast-review")]
933    pub(in crate::web_runtime) fn with_ssrf_oast_review(
934        mut self,
935        config: ssrf_oast_runtime::SsrfOastReviewConfig,
936    ) -> Self {
937        self.assessment_defense_projection = true;
938        self.ssrf_oast_review = Some(config);
939        self
940    }
941
942    /// Reuses this subject runtime for one opt-in matched native review pass.
943    ///
944    /// The native catalog is additive: the standard catalog keeps its existing
945    /// execution semantics, and every executor receives the same broker and
946    /// accounting authority during composition. A missing query parameter
947    /// omits and suppresses only the redirect/reflection action rather than
948    /// inventing transport input.
949    pub(crate) fn with_native_web_review(
950        mut self,
951        seeds: NativeWebReviewSeeds,
952        observer: Arc<dyn CompleteHttpResponseObserver>,
953        redirect_query_parameter: Option<String>,
954        reflection_query_parameter: Option<String>,
955        sql_query_parameter: Option<String>,
956        ssti_query_parameter: Option<String>,
957    ) -> Self {
958        self.assessment_defense_projection = true;
959        self.native_web_review = Some(NativeWebReviewRuntimeConfig {
960            seeds,
961            observer,
962            redirect_query_parameter,
963            reflection_query_parameter,
964            sql_query_parameter,
965            ssti_query_parameter,
966            #[cfg(feature = "template-evaluation-review")]
967            template_evaluation_query_parameter: None,
968            xss_query_parameter: None,
969            xss_selection: None,
970            #[cfg(feature = "normalization-resilience")]
971            normalization_query_parameter: None,
972            #[cfg(feature = "normalization-resilience")]
973            normalization_selection: None,
974            structural_only: false,
975        });
976        self
977    }
978
979    /// Adds the closed harmless template-expression pairs to the already
980    /// configured root native-review pass. The root runtime's existing
981    /// bootstrap is reused; only the four differential legs are added.
982    #[cfg(feature = "template-evaluation-review")]
983    pub(in crate::web_runtime) fn with_native_template_evaluation_parameter(
984        mut self,
985        query_parameter: String,
986    ) -> Self {
987        let config = self
988            .native_web_review
989            .as_mut()
990            .filter(|config| !config.structural_only)
991            .expect("template evaluation augments the configured root native-review pass");
992        config.template_evaluation_query_parameter = Some(query_parameter);
993        self
994    }
995
996    pub(crate) fn with_native_structural_review(
997        mut self,
998        seeds: NativeWebReviewSeeds,
999        observer: Arc<dyn CompleteHttpResponseObserver>,
1000        sql_query_parameter: Option<String>,
1001        ssti_query_parameter: Option<String>,
1002        reflection_query_parameter: Option<String>,
1003    ) -> Self {
1004        self.assessment_defense_projection = true;
1005        self.native_web_review = Some(NativeWebReviewRuntimeConfig {
1006            seeds,
1007            observer,
1008            redirect_query_parameter: None,
1009            reflection_query_parameter,
1010            sql_query_parameter,
1011            ssti_query_parameter,
1012            #[cfg(feature = "template-evaluation-review")]
1013            template_evaluation_query_parameter: None,
1014            xss_query_parameter: None,
1015            xss_selection: None,
1016            #[cfg(feature = "normalization-resilience")]
1017            normalization_query_parameter: None,
1018            #[cfg(feature = "normalization-resilience")]
1019            normalization_selection: None,
1020            structural_only: true,
1021        });
1022        self
1023    }
1024
1025    /// Composes one context-selected structural XSS pair under the existing
1026    /// shared authority. Standard semantic actions are suppressed for this
1027    /// bounded child pass; the bootstrap remains broker-accounted.
1028    pub(crate) fn with_native_xss_structural_review(
1029        mut self,
1030        seeds: NativeWebReviewSeeds,
1031        observer: Arc<dyn CompleteHttpResponseObserver>,
1032        query_parameter: String,
1033        selection: web_assessment::XssProbeSelection,
1034    ) -> Self {
1035        self.assessment_defense_projection = true;
1036        self.additional_suppressed_actions.extend(
1037            StandardWebActionKind::all()
1038                .into_iter()
1039                .map(|kind| kind.action_id().to_owned()),
1040        );
1041        self.native_web_review = Some(NativeWebReviewRuntimeConfig {
1042            seeds,
1043            observer,
1044            redirect_query_parameter: None,
1045            reflection_query_parameter: None,
1046            sql_query_parameter: None,
1047            ssti_query_parameter: None,
1048            #[cfg(feature = "template-evaluation-review")]
1049            template_evaluation_query_parameter: None,
1050            xss_query_parameter: Some(query_parameter),
1051            xss_selection: Some(selection),
1052            #[cfg(feature = "normalization-resilience")]
1053            normalization_query_parameter: None,
1054            #[cfg(feature = "normalization-resilience")]
1055            normalization_selection: None,
1056            structural_only: true,
1057        });
1058        self
1059    }
1060
1061    /// Composes one explicitly selected normalization transformed-candidate /
1062    /// replay pair under the existing exact-origin broker authority.
1063    ///
1064    /// Parent control and canonical-candidate requests are not repeated. The
1065    /// caller must build the observer from a committed
1066    /// [`assessment_review::NormalizationParentEvidence`] contract.
1067    #[cfg(feature = "normalization-resilience")]
1068    pub(in crate::web_runtime) fn with_native_normalization_resilience_review(
1069        mut self,
1070        seeds: NativeWebReviewSeeds,
1071        observer: Arc<dyn CompleteHttpResponseObserver>,
1072        query_parameter: String,
1073        selection: web_assessment::NormalizationTransformSelection,
1074    ) -> Self {
1075        self.assessment_defense_projection = true;
1076        self.additional_suppressed_actions.extend(
1077            StandardWebActionKind::all()
1078                .into_iter()
1079                .map(|kind| kind.action_id().to_owned()),
1080        );
1081        self.native_web_review = Some(NativeWebReviewRuntimeConfig {
1082            seeds,
1083            observer,
1084            redirect_query_parameter: None,
1085            reflection_query_parameter: None,
1086            sql_query_parameter: None,
1087            ssti_query_parameter: None,
1088            #[cfg(feature = "template-evaluation-review")]
1089            template_evaluation_query_parameter: None,
1090            xss_query_parameter: None,
1091            xss_selection: None,
1092            normalization_query_parameter: Some(query_parameter),
1093            normalization_selection: Some(selection),
1094            structural_only: true,
1095        });
1096        self
1097    }
1098
1099    /// Sets the total bootstrap, passive, active, adaptive, and retry request limit.
1100    pub fn max_total_requests(mut self, limit: u32) -> Self {
1101        self.runtime_budget = self.runtime_budget.with_max_total_requests(limit);
1102        self
1103    }
1104
1105    /// Sets the monotonic deadline for the complete runtime.
1106    pub fn max_wall_time(mut self, limit: Duration) -> Self {
1107        self.runtime_budget = self.runtime_budget.with_max_wall_time(limit);
1108        self
1109    }
1110
1111    /// Sets the cumulative transport-delivered response-body threshold.
1112    pub fn max_response_bytes(mut self, limit: u64) -> Self {
1113        self.runtime_budget = self.runtime_budget.with_max_response_bytes(limit);
1114        self
1115    }
1116
1117    /// Sets the maximum number of explicit active verification requests.
1118    pub fn max_active_verifications(mut self, limit: u16) -> Self {
1119        self.runtime_budget = self.runtime_budget.with_max_active_verifications(limit);
1120        self
1121    }
1122
1123    /// Sets the maximum number of attempts for one semantic action.
1124    pub fn max_same_action_attempts(mut self, limit: u16) -> Self {
1125        self.runtime_budget = self.runtime_budget.with_max_same_action_attempts(limit);
1126        self
1127    }
1128
1129    /// Sets the maximum consecutive completed execution turns without progress.
1130    pub fn max_consecutive_no_progress_turns(mut self, limit: u16) -> Self {
1131        self.runtime_budget = self
1132            .runtime_budget
1133            .with_max_consecutive_no_progress_turns(limit);
1134        self
1135    }
1136
1137    /// Validates policy and composes the complete standard runtime.
1138    pub fn build(self) -> Result<StandardWebDecisionRuntime, StandardWebDecisionRuntimeError> {
1139        let policy = match self.http_policy.clone() {
1140            Some(policy) => policy,
1141            None => HttpEvidencePolicy::for_origin(self.target.clone())?,
1142        };
1143        // Preserve the public builder's historical fail-fast order. Target,
1144        // scope, planning, decision, and subject validation all run before the
1145        // reqwest-backed authority is constructed.
1146        self.preflight(|target| policy.require_permitted_target(target))?;
1147        let authority = SharedWebRuntimeAuthority::new_exact_origin(
1148            &self.target,
1149            policy,
1150            self.runtime_budget,
1151            self.cancellation.clone(),
1152        )?;
1153        // Delegate through the same subject-composition seam used by the
1154        // assessment runtime. Its second pure preflight validates the narrowed
1155        // authority but cannot change the already-established public error order.
1156        self.build_with_shared_authority(authority)
1157    }
1158
1159    /// Composes one subject runtime under an already-created origin authority.
1160    ///
1161    /// The authority, rather than this builder's standalone policy/budget/token
1162    /// fields, owns all resource and network capability. This seam remains
1163    /// crate-private so an assessment can create many subject runtimes without
1164    /// exposing a public way to mix independent authorities.
1165    pub(crate) fn build_with_shared_authority(
1166        self,
1167        authority: SharedWebRuntimeAuthority,
1168    ) -> Result<StandardWebDecisionRuntime, StandardWebDecisionRuntimeError> {
1169        let preflight = self.preflight(|target| authority.authorize_target(target))?;
1170        self.compose_with_shared_authority(authority, preflight)
1171    }
1172
1173    fn preflight(
1174        &self,
1175        authorize: impl FnOnce(&Url) -> Result<(), HttpEvidenceError>,
1176    ) -> Result<StandardWebDecisionRuntimePreflight, StandardWebDecisionRuntimeError> {
1177        #[cfg(feature = "rest-review")]
1178        if self.rest_review && self.openapi_review.is_none() {
1179            return Err(StandardWebDecisionRuntimeError::NativeWebReviewDecisionProfile);
1180        }
1181        let probe = HttpProbe::new(self.target.clone(), HttpProbeMethod::Get)?;
1182        authorize(probe.url())?;
1183
1184        let planning = PlanningContext::new(
1185            BenefitScore::from_percent(self.business_value_percent)?,
1186            self.planning_budget,
1187            RiskScore::from_percent(self.risk_limit_percent)?,
1188        );
1189        #[cfg(feature = "authorization-review")]
1190        let max_action_cycles = self.max_action_cycles.saturating_add(
1191            u32::from(self.resource_authorization_review.is_some())
1192                * resource_authorization_runtime::AUTHORIZATION_REVIEW_ACTION_CYCLE_ALLOWANCE,
1193        );
1194        #[cfg(not(feature = "authorization-review"))]
1195        let max_action_cycles = self.max_action_cycles;
1196        #[cfg(feature = "openapi-review")]
1197        let max_action_cycles = max_action_cycles.saturating_add(
1198            u32::from(self.openapi_review.is_some())
1199                * openapi_runtime::OPENAPI_REVIEW_ACTION_CYCLE_ALLOWANCE,
1200        );
1201        #[cfg(feature = "rest-review")]
1202        let max_action_cycles = max_action_cycles.saturating_add(
1203            u32::from(self.rest_review) * rest_runtime::REST_REVIEW_ACTION_CYCLE_ALLOWANCE,
1204        );
1205        #[cfg(feature = "template-evaluation-review")]
1206        let max_action_cycles = max_action_cycles.saturating_add(
1207            u32::from(
1208                self.native_web_review
1209                    .as_ref()
1210                    .and_then(|review| review.template_evaluation_query_parameter.as_ref())
1211                    .is_some(),
1212            ) * u32::from(crate::template_evaluation_review::TEMPLATE_EVALUATION_SELECTED_CASES),
1213        );
1214        #[cfg(feature = "ssrf-oast-review")]
1215        let max_action_cycles = max_action_cycles.saturating_add(
1216            u32::from(self.ssrf_oast_review.is_some())
1217                * ssrf_oast_runtime::SSRF_OAST_REVIEW_ACTION_CYCLE_ALLOWANCE,
1218        );
1219        let config = DecisionLoopConfig::new(
1220            planning,
1221            self.adaptation_limits,
1222            ExperiencePolicy::new(self.experience_failure_limit)?,
1223            max_action_cycles,
1224        )?;
1225        let subject = EntityId::new(format!("endpoint:{}", self.target))?;
1226        Ok(StandardWebDecisionRuntimePreflight { config, subject })
1227    }
1228
1229    fn compose_with_shared_authority(
1230        self,
1231        authority: SharedWebRuntimeAuthority,
1232        preflight: StandardWebDecisionRuntimePreflight,
1233    ) -> Result<StandardWebDecisionRuntime, StandardWebDecisionRuntimeError> {
1234        let StandardWebDecisionRuntimePreflight { config, subject } = preflight;
1235        let mut decision_loop = DecisionLoop::new(config);
1236        let mut executors = DecisionExecutorRegistry::new();
1237
1238        let knowledge = authority.knowledge();
1239        let requests = authority.requests().clone();
1240        let profile = if self.assessment_defense_projection {
1241            StandardWebDecisionProfile::new_with_request_broker_and_assessment_projection(
1242                requests.clone(),
1243            )?
1244        } else {
1245            StandardWebDecisionProfile::new_with_request_broker(requests.clone())?
1246        };
1247        let installation = profile.install(knowledge, &mut decision_loop, &mut executors)?;
1248
1249        #[cfg(feature = "authorization-review")]
1250        let resource_authorization_review = self
1251            .resource_authorization_review
1252            .map(|config| {
1253                authority
1254                    .authorize_target(config.execution_resource())
1255                    .map_err(|_| {
1256                        StandardWebDecisionRuntimeError::ResourceAuthorizationReviewComposition
1257                    })?;
1258                resource_authorization_runtime::ResourceAuthorizationRuntimeBinding::new(
1259                    config,
1260                    requests.clone(),
1261                    subject.clone(),
1262                )
1263                .map_err(|_| {
1264                    StandardWebDecisionRuntimeError::ResourceAuthorizationReviewComposition
1265                })
1266            })
1267            .transpose()?;
1268        #[cfg(feature = "ssrf-oast-review")]
1269        let ssrf_oast_review = self.ssrf_oast_review.map(|config| {
1270            ssrf_oast_runtime::SsrfOastRuntimeBinding::new(
1271                config,
1272                authority.clone(),
1273                subject.clone(),
1274            )
1275        });
1276        #[cfg(feature = "openapi-review")]
1277        let openapi_review = self.openapi_review.map(|config| {
1278            #[cfg(all(feature = "ssrf-oast-review", feature = "openapi-review"))]
1279            if let Some(ssrf) = ssrf_oast_review.as_ref() {
1280                return openapi_runtime::OpenApiRuntimeBinding::new_with_ssrf_selection(
1281                    config,
1282                    requests.clone(),
1283                    subject.clone(),
1284                    knowledge.clone(),
1285                    ssrf.selection_slot(),
1286                );
1287            }
1288            openapi_runtime::OpenApiRuntimeBinding::new(
1289                config,
1290                requests.clone(),
1291                subject.clone(),
1292                knowledge.clone(),
1293            )
1294        });
1295        #[cfg(feature = "rest-review")]
1296        let rest_review = self.rest_review.then(|| {
1297            let openapi = openapi_review
1298                .as_ref()
1299                .expect("REST preflight requires the same-run OpenAPI binding");
1300            rest_runtime::RestReviewBinding::new(
1301                openapi.rest_selection_slot(),
1302                requests.clone(),
1303                subject.clone(),
1304                openapi.response_privacy_is_value_free(),
1305            )
1306        });
1307        let native_executor_profile = match self.native_web_review {
1308            Some(config) => {
1309                let profile = {
1310                    #[cfg(feature = "normalization-resilience")]
1311                    {
1312                        if let (Some(parameter), Some(selection)) = (
1313                            config.normalization_query_parameter,
1314                            config.normalization_selection,
1315                        ) {
1316                            NativeWebReviewExecutorProfile::new_structural_only(
1317                                requests.clone(),
1318                                self.target.clone(),
1319                                config.seeds,
1320                                config.observer,
1321                                NativeWebReviewQueryParameters::normalization_only(
1322                                    parameter, selection,
1323                                ),
1324                            )
1325                        } else if let (Some(parameter), Some(selection)) =
1326                            (config.xss_query_parameter, config.xss_selection)
1327                        {
1328                            NativeWebReviewExecutorProfile::new_structural_only(
1329                                requests.clone(),
1330                                self.target.clone(),
1331                                config.seeds,
1332                                config.observer,
1333                                NativeWebReviewQueryParameters::xss_only(parameter, selection),
1334                            )
1335                        } else if config.structural_only {
1336                            NativeWebReviewExecutorProfile::new_structural_only(
1337                                requests.clone(),
1338                                self.target.clone(),
1339                                config.seeds,
1340                                config.observer,
1341                                NativeWebReviewQueryParameters::structural(
1342                                    config.reflection_query_parameter,
1343                                    config.sql_query_parameter,
1344                                    config.ssti_query_parameter,
1345                                ),
1346                            )
1347                        } else {
1348                            NativeWebReviewExecutorProfile::new(
1349                                requests.clone(),
1350                                self.target.clone(),
1351                                config.seeds,
1352                                config.observer,
1353                                NativeWebReviewQueryParameters::full(
1354                                    config.redirect_query_parameter,
1355                                    config.reflection_query_parameter,
1356                                    config.sql_query_parameter,
1357                                    config.ssti_query_parameter,
1358                                    #[cfg(feature = "template-evaluation-review")]
1359                                    config.template_evaluation_query_parameter,
1360                                ),
1361                            )
1362                        }
1363                    }
1364                    #[cfg(not(feature = "normalization-resilience"))]
1365                    {
1366                        if let (Some(parameter), Some(selection)) =
1367                            (config.xss_query_parameter, config.xss_selection)
1368                        {
1369                            NativeWebReviewExecutorProfile::new_structural_only(
1370                                requests.clone(),
1371                                self.target.clone(),
1372                                config.seeds,
1373                                config.observer,
1374                                NativeWebReviewQueryParameters::xss_only(parameter, selection),
1375                            )
1376                        } else if config.structural_only {
1377                            NativeWebReviewExecutorProfile::new_structural_only(
1378                                requests.clone(),
1379                                self.target.clone(),
1380                                config.seeds,
1381                                config.observer,
1382                                NativeWebReviewQueryParameters::structural(
1383                                    config.reflection_query_parameter,
1384                                    config.sql_query_parameter,
1385                                    config.ssti_query_parameter,
1386                                ),
1387                            )
1388                        } else {
1389                            NativeWebReviewExecutorProfile::new(
1390                                requests.clone(),
1391                                self.target.clone(),
1392                                config.seeds,
1393                                config.observer,
1394                                NativeWebReviewQueryParameters::full(
1395                                    config.redirect_query_parameter,
1396                                    config.reflection_query_parameter,
1397                                    config.sql_query_parameter,
1398                                    config.ssti_query_parameter,
1399                                    #[cfg(feature = "template-evaluation-review")]
1400                                    config.template_evaluation_query_parameter,
1401                                ),
1402                            )
1403                        }
1404                    }
1405                };
1406                Some(profile.map_err(|_| {
1407                    StandardWebDecisionRuntimeError::NativeWebReviewExecutionProfile
1408                })?)
1409            },
1410            None => None,
1411        };
1412        let native_executor_actions = native_executor_profile
1413            .as_ref()
1414            .map(|profile| profile.actions().collect::<Vec<_>>())
1415            .unwrap_or_default();
1416        #[cfg(any(
1417            feature = "authorization-review",
1418            feature = "openapi-review",
1419            feature = "rest-review",
1420            feature = "ssrf-oast-review"
1421        ))]
1422        let mut native_review_actions = native_executor_actions.clone();
1423        #[cfg(not(any(
1424            feature = "authorization-review",
1425            feature = "openapi-review",
1426            feature = "rest-review",
1427            feature = "ssrf-oast-review"
1428        )))]
1429        let native_review_actions = native_executor_actions.clone();
1430        #[cfg(feature = "authorization-review")]
1431        if resource_authorization_review.is_some() {
1432            native_review_actions.push(
1433                crate::web_actions::NativeWebReviewActionKind::ResourceAuthorizationDifferential,
1434            );
1435        }
1436        #[cfg(feature = "openapi-review")]
1437        if openapi_review.is_some() {
1438            native_review_actions
1439                .push(crate::web_actions::NativeWebReviewActionKind::OpenApiDocumentReplay);
1440        }
1441        #[cfg(feature = "rest-review")]
1442        if rest_review.is_some() {
1443            native_review_actions
1444                .push(crate::web_actions::NativeWebReviewActionKind::RestReadOnlyReplay);
1445        }
1446        #[cfg(feature = "ssrf-oast-review")]
1447        if ssrf_oast_review.is_some() {
1448            native_review_actions
1449                .push(crate::web_actions::NativeWebReviewActionKind::SsrfOastQueryReview);
1450        }
1451        if !native_review_actions.is_empty() {
1452            let profile =
1453                NativeWebReviewDecisionProfile::for_actions(native_review_actions.iter().copied())
1454                    .map_err(|_| StandardWebDecisionRuntimeError::NativeWebReviewDecisionProfile)?;
1455            debug_assert_eq!(profile.actions().collect::<Vec<_>>(), native_review_actions);
1456            let report = profile
1457                .install(&mut decision_loop)
1458                .map_err(|_| StandardWebDecisionRuntimeError::NativeWebReviewDecisionProfile)?;
1459            #[cfg(feature = "rest-review")]
1460            let rest_count = usize::from(rest_review.is_some());
1461            #[cfg(not(feature = "rest-review"))]
1462            let rest_count = 0;
1463            #[cfg(feature = "ssrf-oast-review")]
1464            let ssrf_oast_count = usize::from(ssrf_oast_review.is_some());
1465            #[cfg(not(feature = "ssrf-oast-review"))]
1466            let ssrf_oast_count = 0;
1467            let non_rest_count = native_review_actions
1468                .len()
1469                .saturating_sub(rest_count)
1470                .saturating_sub(ssrf_oast_count);
1471            debug_assert_eq!(
1472                report.reasoning_rules_inserted,
1473                usize::from(non_rest_count > 0) + rest_count + ssrf_oast_count
1474            );
1475            debug_assert_eq!(report.actions_inserted, native_review_actions.len());
1476            #[cfg(feature = "authorization-review")]
1477            let authorization_count = usize::from(resource_authorization_review.is_some());
1478            #[cfg(not(feature = "authorization-review"))]
1479            let authorization_count = 0;
1480            #[cfg(feature = "openapi-review")]
1481            let openapi_count = usize::from(openapi_review.is_some());
1482            #[cfg(not(feature = "openapi-review"))]
1483            let openapi_count = 0;
1484            debug_assert_eq!(
1485                report.passive_rules_inserted,
1486                authorization_count
1487                    + (2 * openapi_count)
1488                    + (2 * rest_count)
1489                    + (2 * ssrf_oast_count)
1490            );
1491            debug_assert_eq!(
1492                report.active_rules_inserted,
1493                native_review_actions.len()
1494                    + authorization_count
1495                    + openapi_count
1496                    + rest_count
1497                    + ssrf_oast_count
1498            );
1499        }
1500
1501        // Surface-B multi-objective continuation: install continuation rules ONLY
1502        // in this runtime's adaptive pipeline. The generic AdaptivePipeline
1503        // fallback is unchanged, so library hosts keep single-objective semantics.
1504        for rule in standard_web_continuation_rules().map_err(DecisionLoopError::Adaptive)? {
1505            decision_loop
1506                .adaptive_mut()
1507                .register(rule)
1508                .map_err(DecisionLoopError::Adaptive)?;
1509        }
1510        #[cfg(feature = "authorization-review")]
1511        if resource_authorization_review.is_some() {
1512            decision_loop
1513                .adaptive_mut()
1514                .register(
1515                    resource_authorization_terminal_adaptation_rule()
1516                        .map_err(DecisionLoopError::Adaptive)?,
1517                )
1518                .map_err(DecisionLoopError::Adaptive)?;
1519        }
1520        let api_reasoning_installation = if self.api_reasoning_enabled {
1521            let profile = StandardApiReasoning::new()?;
1522            Some(profile.install(knowledge, decision_loop.rules_mut())?)
1523        } else {
1524            None
1525        };
1526        let http_evidence = HttpEvidenceExecutor::new_with_request_broker(
1527            requests.clone(),
1528            Arc::new(SubjectHttpProbeProvider::new(self.bootstrap_probe_method)),
1529        )?;
1530        let http_evidence = match self.payload_binding {
1531            Some(binding) => http_evidence.with_payload_binding(binding),
1532            None => http_evidence,
1533        };
1534        let http_evidence = match self.complete_response_observer {
1535            Some(observer) => http_evidence.with_complete_response_observer(observer),
1536            None => http_evidence,
1537        };
1538        let http_evidence = if self.assessment_defense_projection {
1539            http_evidence.with_assessment_defense_projection()
1540        } else {
1541            http_evidence
1542        };
1543        executors.register(Arc::new(http_evidence))?;
1544
1545        if let Some(profile) = native_executor_profile {
1546            debug_assert_eq!(
1547                profile.actions().collect::<Vec<_>>(),
1548                native_executor_actions
1549            );
1550            let report = profile
1551                .install(&mut executors)
1552                .map_err(|_| StandardWebDecisionRuntimeError::NativeWebReviewExecutionProfile)?;
1553            debug_assert_eq!(report.executors_inserted(), native_executor_actions.len());
1554        }
1555
1556        #[cfg(feature = "authorization-review")]
1557        if let Some(binding) = resource_authorization_review.as_ref() {
1558            binding
1559                .install_into_parent_registry(&mut executors)
1560                .map_err(|_| {
1561                    StandardWebDecisionRuntimeError::ResourceAuthorizationReviewComposition
1562                })?;
1563        }
1564        #[cfg(feature = "openapi-review")]
1565        if let Some(binding) = openapi_review.as_ref() {
1566            binding
1567                .install_into_parent_registry(&mut executors)
1568                .map_err(|_| StandardWebDecisionRuntimeError::NativeWebReviewExecutionProfile)?;
1569        }
1570        #[cfg(feature = "rest-review")]
1571        if let Some(binding) = rest_review.as_ref() {
1572            binding
1573                .install_into_parent_registry(&mut executors)
1574                .map_err(|_| StandardWebDecisionRuntimeError::NativeWebReviewExecutionProfile)?;
1575        }
1576        #[cfg(feature = "ssrf-oast-review")]
1577        if let Some(binding) = ssrf_oast_review.as_ref() {
1578            binding
1579                .install_into_parent_registry(&mut executors)
1580                .map_err(|_| StandardWebDecisionRuntimeError::NativeWebReviewExecutionProfile)?;
1581        }
1582
1583        let mut unsupported_actions: BTreeSet<_> = StandardWebActionKind::all()
1584            .into_iter()
1585            .filter(|kind| !executors.contains(kind.executor_id()))
1586            .map(|kind| kind.action_id().to_owned())
1587            .collect();
1588        unsupported_actions.extend(self.additional_suppressed_actions);
1589
1590        Ok(StandardWebDecisionRuntime {
1591            target: self.target,
1592            subject: subject.clone(),
1593            installation,
1594            api_reasoning_installation,
1595            unsupported_actions,
1596            decision_loop,
1597            runner: DecisionRunnerAdapter::new(executors),
1598            experience: self.experience,
1599            session: DecisionSession::new(subject),
1600            authority,
1601            usage: RuntimeUsage::default(),
1602            started: false,
1603            assessment_defense: self
1604                .assessment_defense_projection
1605                .then(|| AssessmentDefenseController::new(self.assessment_defense_enforcement)),
1606            #[cfg(feature = "authorization-review")]
1607            resource_authorization_review,
1608            #[cfg(feature = "openapi-review")]
1609            openapi_review,
1610            #[cfg(feature = "rest-review")]
1611            rest_review,
1612            #[cfg(feature = "ssrf-oast-review")]
1613            ssrf_oast_review,
1614        })
1615    }
1616}
1617
1618/// Single-use target runtime for evidence collection and deterministic decisions.
1619///
1620/// # Examples
1621///
1622/// ```rust,no_run
1623/// use url::Url;
1624/// use termivar_scanner::StandardWebDecisionRuntime;
1625///
1626/// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
1627/// let target = Url::parse("https://example.test/")?;
1628/// let mut runtime = StandardWebDecisionRuntime::builder(target)
1629///     .planning_budget(100)
1630///     .risk_limit(40)
1631///     .max_action_cycles(8)
1632///     .enable_api_reasoning()
1633///     .build()?;
1634///
1635/// let report = runtime.analyze().await?;
1636/// println!("terminal command: {:?}", report.terminal());
1637/// # Ok(())
1638/// # }
1639/// ```
1640pub struct StandardWebDecisionRuntime {
1641    target: Url,
1642    subject: EntityId,
1643    installation: StandardWebDecisionInstallReport,
1644    api_reasoning_installation: Option<StandardApiInstallReport>,
1645    unsupported_actions: BTreeSet<String>,
1646    decision_loop: DecisionLoop,
1647    runner: DecisionRunnerAdapter,
1648    experience: ExperienceStore,
1649    session: DecisionSession,
1650    authority: SharedWebRuntimeAuthority,
1651    usage: RuntimeUsage,
1652    started: bool,
1653    assessment_defense: Option<AssessmentDefenseController>,
1654    #[cfg(feature = "authorization-review")]
1655    resource_authorization_review:
1656        Option<resource_authorization_runtime::ResourceAuthorizationRuntimeBinding>,
1657    #[cfg(feature = "openapi-review")]
1658    openapi_review: Option<openapi_runtime::OpenApiRuntimeBinding>,
1659    #[cfg(feature = "rest-review")]
1660    rest_review: Option<rest_runtime::RestReviewBinding>,
1661    #[cfg(feature = "ssrf-oast-review")]
1662    ssrf_oast_review: Option<ssrf_oast_runtime::SsrfOastRuntimeBinding>,
1663}
1664
1665impl StandardWebDecisionRuntime {
1666    /// Starts a target-scoped runtime builder.
1667    pub fn builder(target: Url) -> StandardWebDecisionRuntimeBuilder {
1668        StandardWebDecisionRuntimeBuilder::new(target)
1669    }
1670
1671    /// Returns the authorized target supplied by the host.
1672    pub fn target(&self) -> &Url {
1673        &self.target
1674    }
1675
1676    /// Returns the stable endpoint subject used by every runtime layer.
1677    pub fn subject(&self) -> &EntityId {
1678        &self.subject
1679    }
1680
1681    /// Returns the standard profile installation receipt.
1682    pub fn installation(&self) -> StandardWebDecisionInstallReport {
1683        self.installation
1684    }
1685
1686    /// Returns the passive API reasoning installation receipt when enabled.
1687    pub fn api_reasoning_installation(&self) -> Option<StandardApiInstallReport> {
1688        self.api_reasoning_installation
1689    }
1690
1691    /// Returns actions omitted because no executor was installed for them.
1692    pub fn unsupported_actions(&self) -> &BTreeSet<String> {
1693        &self.unsupported_actions
1694    }
1695
1696    /// Returns the runtime knowledge base for audit and reporting.
1697    pub fn knowledge(&self) -> &KnowledgeBase {
1698        self.authority.knowledge()
1699    }
1700
1701    /// Returns learned target-scoped outcomes.
1702    pub fn experience(&self) -> &ExperienceStore {
1703        &self.experience
1704    }
1705
1706    /// Returns the replayable session state.
1707    pub fn session(&self) -> &DecisionSession {
1708        &self.session
1709    }
1710
1711    /// Returns the immutable resource envelope for this session.
1712    pub const fn budget(&self) -> RuntimeBudget {
1713        self.authority.budget()
1714    }
1715
1716    /// Returns current resource accounting, including failed request attempts.
1717    pub fn usage(&self) -> &RuntimeUsage {
1718        &self.usage
1719    }
1720
1721    /// Returns a clone of the host-owned cancellation token.
1722    ///
1723    /// Cancelling the returned token stops this single-use runtime at its next
1724    /// async or deterministic planning boundary.
1725    pub fn cancellation_token(&self) -> CancellationToken {
1726        self.authority.cancellation_token()
1727    }
1728
1729    /// Returns whether execution has been attempted.
1730    pub fn has_started(&self) -> bool {
1731        self.started
1732    }
1733
1734    /// Consumes the runtime and returns its learned experience.
1735    pub fn into_experience(self) -> ExperienceStore {
1736        self.experience
1737    }
1738
1739    fn action_suppression_context(&self) -> Result<ActionSuppressionContext, ()> {
1740        let defense = match &self.assessment_defense {
1741            Some(controller) => controller
1742                .defense_suppressed_actions(&self.subject, self.decision_loop.planner())?,
1743            None => BTreeSet::new(),
1744        };
1745        Ok(ActionSuppressionContext::new(
1746            self.unsupported_actions.clone(),
1747            defense,
1748        ))
1749    }
1750
1751    fn ingest_assessment_defense(
1752        &mut self,
1753        receipt: &DecisionEvidenceReceipt,
1754        require_projection: bool,
1755    ) -> Result<(), ()> {
1756        let Some(controller) = self.assessment_defense.as_mut() else {
1757            return Ok(());
1758        };
1759        controller.ingest_receipt(receipt, self.authority.knowledge(), require_projection)
1760    }
1761
1762    fn record_assessment_defense_shadow(
1763        &mut self,
1764        report: &DecisionPlanningReport,
1765    ) -> Result<(), ()> {
1766        let Some(controller) = self.assessment_defense.as_mut() else {
1767            return Ok(());
1768        };
1769        let planner = self.decision_loop.planner().clone();
1770        controller.record_shadow(report, &planner)
1771    }
1772
1773    pub(crate) fn assessment_defense_controller(&self) -> Option<&AssessmentDefenseController> {
1774        self.assessment_defense.as_ref()
1775    }
1776
1777    pub(crate) fn assessment_planner(&self) -> &crate::AttackPlanner {
1778        self.decision_loop.planner()
1779    }
1780
1781    #[cfg(feature = "authorization-review")]
1782    pub(in crate::web_runtime) fn take_resource_authorization_review(
1783        &mut self,
1784    ) -> Option<resource_authorization_runtime::ResourceAuthorizationRuntimeBinding> {
1785        self.resource_authorization_review.take()
1786    }
1787
1788    #[cfg(feature = "openapi-review")]
1789    pub(in crate::web_runtime) fn take_openapi_review(
1790        &mut self,
1791    ) -> Option<openapi_runtime::OpenApiRuntimeBinding> {
1792        self.openapi_review.take()
1793    }
1794
1795    #[cfg(feature = "rest-review")]
1796    pub(in crate::web_runtime) fn take_rest_review(
1797        &mut self,
1798    ) -> Option<rest_runtime::RestReviewBinding> {
1799        self.rest_review.take()
1800    }
1801
1802    #[cfg(feature = "ssrf-oast-review")]
1803    pub(in crate::web_runtime) fn take_ssrf_oast_review(
1804        &mut self,
1805    ) -> Option<ssrf_oast_runtime::SsrfOastRuntimeBinding> {
1806        self.ssrf_oast_review.take()
1807    }
1808
1809    /// Collects bootstrap evidence and drives commands to a terminal state.
1810    ///
1811    /// The runtime is single-use even when execution returns an error. This
1812    /// prevents a caller from replaying a partially committed network session
1813    /// under the same deterministic case identities.
1814    pub async fn analyze(
1815        &mut self,
1816    ) -> Result<StandardWebDecisionRunReport, StandardWebDecisionRuntimeError> {
1817        if self.started {
1818            return Err(StandardWebDecisionRuntimeError::AlreadyStarted);
1819        }
1820        self.started = true;
1821        let timing = self.authority.start();
1822        let started_at = timing.started_at();
1823        let deadline = timing.deadline();
1824        let mut turns = Vec::new();
1825
1826        if self.authority.cancellation().is_cancelled() {
1827            return Ok(self.cancellation_report(None, turns, None, started_at));
1828        }
1829
1830        let bootstrap_case = match VerificationCase::new(
1831            BOOTSTRAP_CASE_ID,
1832            self.subject.clone(),
1833            BOOTSTRAP_ACTION_ID,
1834            BOOTSTRAP_HYPOTHESIS_ID,
1835        ) {
1836            Ok(case) => case,
1837            Err(source) => {
1838                return Err(self.run_failed(None, turns, source.into(), started_at));
1839            },
1840        };
1841        let bootstrap_command = DecisionLoopCommand::ExecuteAction {
1842            case: bootstrap_case,
1843            executor: Some(HTTP_EVIDENCE_EXECUTOR_ID.to_owned()),
1844            origin: DecisionActionOrigin::Bootstrap,
1845            delay_ms: None,
1846        };
1847        let (bootstrap_action_id, bootstrap_stage) = match execution_metadata(&bootstrap_command) {
1848            Ok(metadata) => metadata,
1849            Err(source) => {
1850                return Err(self.run_failed(None, turns, source, started_at));
1851            },
1852        };
1853        // Bootstrap is always the transport-bound HTTP evidence probe.
1854        let bootstrap_limits = match self.reserve_execution(
1855            bootstrap_action_id,
1856            bootstrap_stage,
1857            DecisionExecutionClass::TransportBound,
1858            started_at,
1859        ) {
1860            Ok(limits) => limits,
1861            Err(limit) => {
1862                if self.authority.cancellation().is_cancelled() {
1863                    return Ok(self.cancellation_report(None, turns, None, started_at));
1864                }
1865                return Ok(self.limit_report(None, turns, limit, started_at));
1866            },
1867        };
1868        if self.authority.cancellation().is_cancelled() {
1869            return Ok(self.cancellation_report(None, turns, None, started_at));
1870        }
1871        let bootstrap_result = await_execution(
1872            self.authority.cancellation(),
1873            deadline,
1874            self.runner.execute_command_with_limits(
1875                &bootstrap_command,
1876                self.authority.knowledge(),
1877                bootstrap_limits,
1878            ),
1879        )
1880        .await;
1881        let bootstrap = match bootstrap_result {
1882            RuntimeExecution::Completed(Ok(receipt)) => {
1883                self.refresh_elapsed(started_at);
1884                receipt
1885            },
1886            RuntimeExecution::Completed(Err(error)) => {
1887                self.refresh_elapsed(started_at);
1888                if let Some(limit) = error.runtime_limit().cloned() {
1889                    let failure = error.into_execution_failure();
1890                    return Ok(
1891                        self.limit_report_with_failure(None, turns, limit, failure, started_at)
1892                    );
1893                }
1894                return Err(self.run_failed(None, turns, error.into(), started_at));
1895            },
1896            RuntimeExecution::Cancelled => {
1897                return Ok(self.cancellation_report(None, turns, None, started_at));
1898            },
1899            RuntimeExecution::WallTimeExceeded => {
1900                let limit = self.wall_limit(started_at);
1901                return Ok(self.limit_report(None, turns, limit, started_at));
1902            },
1903        };
1904        let bootstrap = match self
1905            .validate_response_usage_evidence(bootstrap, DecisionExecutionClass::TransportBound)
1906        {
1907            Ok(receipt) => receipt,
1908            Err(source) => {
1909                let committed_bootstrap = source.committed_evidence().cloned();
1910                return Err(self.run_failed(committed_bootstrap, turns, source, started_at));
1911            },
1912        };
1913        if self.ingest_assessment_defense(&bootstrap, true).is_err() {
1914            let source = StandardWebDecisionRuntimeError::AssessmentDefenseProjectionInvariant {
1915                receipt: Box::new(bootstrap),
1916            };
1917            let committed_bootstrap = source.committed_evidence().cloned();
1918            return Err(self.run_failed(committed_bootstrap, turns, source, started_at));
1919        }
1920        if let Some(limit) = self.response_limit_if_exceeded(BOOTSTRAP_ACTION_ID) {
1921            return Ok(self.limit_report(Some(bootstrap), turns, limit, started_at));
1922        }
1923        let bootstrap = Some(bootstrap);
1924
1925        if self.authority.cancellation().is_cancelled() {
1926            return Ok(self.cancellation_report(bootstrap, turns, None, started_at));
1927        }
1928
1929        let mut command = DecisionLoopCommand::Replan;
1930        // Deterministic representatives for the synthesized aggregate terminal:
1931        // the first success case, and the first unresolved (blocked /
1932        // active-inconclusive) case, in dispatch order.
1933        let mut representative_success: Option<VerificationCase> = None;
1934        let mut representative_unresolved: Option<VerificationCase> = None;
1935        let terminal = loop {
1936            match &command {
1937                DecisionLoopCommand::Replan => {
1938                    if self.authority.cancellation().is_cancelled() {
1939                        return Ok(self.cancellation_report(bootstrap, turns, None, started_at));
1940                    }
1941                    if let Some(limit) = self.wall_limit_if_reached(started_at) {
1942                        return Ok(self.limit_report(bootstrap, turns, limit, started_at));
1943                    }
1944                    let suppressions = match self.action_suppression_context() {
1945                        Ok(value) => value,
1946                        Err(()) => {
1947                            return Err(self.run_failed(
1948                                bootstrap,
1949                                turns,
1950                                StandardWebDecisionRuntimeError::AssessmentDefensePlanningInvariant,
1951                                started_at,
1952                            ));
1953                        },
1954                    };
1955                    let planning = match self.decision_loop.plan_next_with_action_suppressions(
1956                        self.authority.knowledge(),
1957                        &self.experience,
1958                        &mut self.session,
1959                        &suppressions,
1960                    ) {
1961                        Ok(planning) => planning,
1962                        Err(source) => {
1963                            return Err(self.run_failed(
1964                                bootstrap,
1965                                turns,
1966                                source.into(),
1967                                started_at,
1968                            ));
1969                        },
1970                    };
1971                    if self.record_assessment_defense_shadow(&planning).is_err() {
1972                        return Err(self.run_failed(
1973                            bootstrap,
1974                            turns,
1975                            StandardWebDecisionRuntimeError::AssessmentDefensePlanningInvariant,
1976                            started_at,
1977                        ));
1978                    }
1979                    command = planning.command().clone();
1980                    turns.push(StandardWebDecisionRuntimeTurn::Planning(Box::new(planning)));
1981                    if self.authority.cancellation().is_cancelled() {
1982                        return Ok(self.cancellation_report(bootstrap, turns, None, started_at));
1983                    }
1984                    if is_terminal(&command) {
1985                        break command.clone();
1986                    }
1987                    if let Some(limit) = self.wall_limit_if_reached(started_at) {
1988                        return Ok(self.limit_report(bootstrap, turns, limit, started_at));
1989                    }
1990                },
1991                DecisionLoopCommand::ExecuteAction { .. }
1992                | DecisionLoopCommand::CollectActiveEvidence { .. } => {
1993                    if self.authority.cancellation().is_cancelled() {
1994                        return Ok(self.cancellation_report(bootstrap, turns, None, started_at));
1995                    }
1996                    let predispatch_suppressions = match self.action_suppression_context() {
1997                        Ok(value) => value,
1998                        Err(()) => {
1999                            return Err(self.run_failed(
2000                                bootstrap,
2001                                turns,
2002                                StandardWebDecisionRuntimeError::AssessmentDefensePlanningInvariant,
2003                                started_at,
2004                            ));
2005                        },
2006                    };
2007                    match self
2008                        .runner
2009                        .validate_command_suppression(&command, &predispatch_suppressions)
2010                    {
2011                        Err(source @ DecisionRunnerError::ActionSuppressedByDefense { .. }) => {
2012                            if let Err(source) =
2013                                self.decision_loop.validate_execution_command_authority(
2014                                    self.authority.knowledge(),
2015                                    &command,
2016                                )
2017                            {
2018                                return Err(self.run_failed(
2019                                    bootstrap,
2020                                    turns,
2021                                    source.into(),
2022                                    started_at,
2023                                ));
2024                            }
2025                            match self.runner.replan_defense_suppressed_command(
2026                                &command,
2027                                &mut self.session,
2028                                &predispatch_suppressions,
2029                            ) {
2030                                Ok(true) => {
2031                                    command = DecisionLoopCommand::Replan;
2032                                    continue;
2033                                },
2034                                Ok(false) => {
2035                                    return Err(self.run_failed(
2036                                        bootstrap,
2037                                        turns,
2038                                        source.into(),
2039                                        started_at,
2040                                    ));
2041                                },
2042                                Err(source) => {
2043                                    return Err(self.run_failed(
2044                                        bootstrap,
2045                                        turns,
2046                                        source.into(),
2047                                        started_at,
2048                                    ));
2049                                },
2050                            }
2051                        },
2052                        Err(source) => {
2053                            return Err(self.run_failed(
2054                                bootstrap,
2055                                turns,
2056                                source.into(),
2057                                started_at,
2058                            ));
2059                        },
2060                        Ok(()) => {},
2061                    }
2062                    let (action_id, previous_stage) = match execution_metadata(&command) {
2063                        Ok(metadata) => metadata,
2064                        Err(source) => {
2065                            return Err(self.run_failed(bootstrap, turns, source, started_at));
2066                        },
2067                    };
2068                    let execution_class = match self.runner.execution_class_for_command(&command) {
2069                        Ok(class) => class,
2070                        Err(source) => {
2071                            return Err(self.run_failed(
2072                                bootstrap,
2073                                turns,
2074                                source.into(),
2075                                started_at,
2076                            ));
2077                        },
2078                    };
2079                    let completed_action_id = action_id.to_owned();
2080                    let limits = match self.reserve_execution(
2081                        action_id,
2082                        previous_stage,
2083                        execution_class,
2084                        started_at,
2085                    ) {
2086                        Ok(limits) => limits,
2087                        Err(limit) => {
2088                            if self.authority.cancellation().is_cancelled() {
2089                                return Ok(
2090                                    self.cancellation_report(bootstrap, turns, None, started_at)
2091                                );
2092                            }
2093                            return Ok(self.limit_report(bootstrap, turns, limit, started_at));
2094                        },
2095                    };
2096                    if self.authority.cancellation().is_cancelled() {
2097                        return Ok(self.cancellation_report(bootstrap, turns, None, started_at));
2098                    }
2099                    let evidence_result = await_execution(
2100                        self.authority.cancellation(),
2101                        deadline,
2102                        self.runner.execute_session_command_with_limits(
2103                            &command,
2104                            self.authority.knowledge(),
2105                            &self.session,
2106                            limits,
2107                        ),
2108                    )
2109                    .await;
2110                    let evidence = match evidence_result {
2111                        RuntimeExecution::Completed(Ok(receipt)) => {
2112                            self.refresh_elapsed(started_at);
2113                            receipt
2114                        },
2115                        RuntimeExecution::Completed(Err(error)) => {
2116                            self.refresh_elapsed(started_at);
2117                            if let Some(limit) = error.runtime_limit().cloned() {
2118                                let failure = error.into_execution_failure();
2119                                return Ok(self.limit_report_with_failure(
2120                                    bootstrap, turns, limit, failure, started_at,
2121                                ));
2122                            }
2123                            return Err(self.run_failed(
2124                                bootstrap,
2125                                turns,
2126                                error.into(),
2127                                started_at,
2128                            ));
2129                        },
2130                        RuntimeExecution::Cancelled => {
2131                            return Ok(self.cancellation_report(bootstrap, turns, None, started_at));
2132                        },
2133                        RuntimeExecution::WallTimeExceeded => {
2134                            let limit = self.wall_limit(started_at);
2135                            return Ok(self.limit_report(bootstrap, turns, limit, started_at));
2136                        },
2137                    };
2138                    let evidence =
2139                        match self.validate_response_usage_evidence(evidence, execution_class) {
2140                            Ok(receipt) => receipt,
2141                            Err(source) => {
2142                                return Err(self.run_failed(bootstrap, turns, source, started_at));
2143                            },
2144                        };
2145                    if self
2146                        .ingest_assessment_defense(
2147                            &evidence,
2148                            execution_class == DecisionExecutionClass::TransportBound,
2149                        )
2150                        .is_err()
2151                    {
2152                        let source =
2153                            StandardWebDecisionRuntimeError::AssessmentDefenseProjectionInvariant {
2154                                receipt: Box::new(evidence),
2155                            };
2156                        return Err(self.run_failed(bootstrap, turns, source, started_at));
2157                    }
2158                    // Cumulative response-byte enforcement is a transport concern;
2159                    // a local-knowledge action delivers no response bytes.
2160                    if execution_class == DecisionExecutionClass::TransportBound {
2161                        if let Some(limit) = self.response_limit_if_exceeded(&completed_action_id) {
2162                            return Ok(self.limit_report_with_unverified_evidence(
2163                                bootstrap, turns, evidence, limit, started_at,
2164                            ));
2165                        }
2166                    }
2167                    if self.authority.cancellation().is_cancelled() {
2168                        return Ok(self.cancellation_report(
2169                            bootstrap,
2170                            turns,
2171                            Some(evidence),
2172                            started_at,
2173                        ));
2174                    }
2175                    let resume_suppressions = match self.action_suppression_context() {
2176                        Ok(value) => value,
2177                        Err(()) => {
2178                            return Err(self.run_failed(
2179                                bootstrap,
2180                                turns,
2181                                StandardWebDecisionRuntimeError::AssessmentDefensePlanningInvariant,
2182                                started_at,
2183                            ));
2184                        },
2185                    };
2186                    let runner_turn = self.runner.resume_session_command_with_action_suppressions(
2187                        &self.decision_loop,
2188                        &command,
2189                        self.authority.knowledge(),
2190                        &mut self.experience,
2191                        &mut self.session,
2192                        ContinuationAuthority::new(evidence, &resume_suppressions),
2193                    );
2194                    self.refresh_elapsed(started_at);
2195                    let runner_turn = match runner_turn {
2196                        Ok(turn) => turn,
2197                        Err(source) => {
2198                            return Err(self.run_failed(
2199                                bootstrap,
2200                                turns,
2201                                source.into(),
2202                                started_at,
2203                            ));
2204                        },
2205                    };
2206                    match runner_turn {
2207                        DecisionRunnerTurn::Planning(planning) => {
2208                            if self.record_assessment_defense_shadow(&planning).is_err() {
2209                                return Err(self.run_failed(
2210                                    bootstrap,
2211                                    turns,
2212                                    StandardWebDecisionRuntimeError::
2213                                        AssessmentDefensePlanningInvariant,
2214                                    started_at,
2215                                ));
2216                            }
2217                            command = planning.command().clone();
2218                            turns.push(StandardWebDecisionRuntimeTurn::Planning(planning));
2219                            if is_terminal(&command) {
2220                                break command.clone();
2221                            }
2222                            if self.authority.cancellation().is_cancelled() {
2223                                return Ok(
2224                                    self.cancellation_report(bootstrap, turns, None, started_at)
2225                                );
2226                            }
2227                        },
2228                        DecisionRunnerTurn::Outcome { evidence, decision } => {
2229                            command = decision.command().clone();
2230                            let progressed =
2231                                outcome_made_progress(previous_stage, &command, decision.as_ref());
2232                            self.usage.record_execution_progress(progressed);
2233                            classify_continuation_case(
2234                                decision.as_ref(),
2235                                &mut representative_success,
2236                                &mut representative_unresolved,
2237                            );
2238                            turns.push(StandardWebDecisionRuntimeTurn::Outcome {
2239                                evidence,
2240                                decision,
2241                            });
2242                            if is_terminal(&command) {
2243                                break command.clone();
2244                            }
2245                            if self.authority.cancellation().is_cancelled() {
2246                                return Ok(
2247                                    self.cancellation_report(bootstrap, turns, None, started_at)
2248                                );
2249                            }
2250                            if self.usage.consecutive_no_progress_turns()
2251                                >= self.authority.budget().max_consecutive_no_progress_turns()
2252                                && !progressed
2253                            {
2254                                let limit = RuntimeLimitExceeded::new(
2255                                    RuntimeBudgetDimension::ConsecutiveNoProgressTurns,
2256                                    u64::from(
2257                                        self.authority.budget().max_consecutive_no_progress_turns(),
2258                                    ),
2259                                    u64::from(self.usage.consecutive_no_progress_turns()),
2260                                    Some(completed_action_id),
2261                                );
2262                                return Ok(self.limit_report(bootstrap, turns, limit, started_at));
2263                            }
2264                            if let Some(limit) = self.wall_limit_if_reached(started_at) {
2265                                return Ok(self.limit_report(bootstrap, turns, limit, started_at));
2266                            }
2267                        },
2268                        DecisionRunnerTurn::Terminal(terminal) => break terminal,
2269                    }
2270                },
2271                DecisionLoopCommand::Complete { .. }
2272                | DecisionLoopCommand::AwaitHumanReview { .. }
2273                | DecisionLoopCommand::Halt { .. } => break command.clone(),
2274            }
2275        };
2276
2277        // Synthesize the aggregate terminal from the recorded outcomes, and keep
2278        // the session state in agreement with it.
2279        let terminal = self.finalize_multi_objective_terminal(
2280            terminal,
2281            representative_success,
2282            representative_unresolved,
2283        );
2284
2285        self.refresh_elapsed(started_at);
2286
2287        Ok(StandardWebDecisionRunReport {
2288            bootstrap,
2289            turns,
2290            unverified_evidence: None,
2291            terminal,
2292            usage: self.usage.clone(),
2293            transport: self.authority.request_accounting().dispatch_audit(),
2294            limit_exceeded: None,
2295            execution_failure: None,
2296        })
2297    }
2298
2299    /// Synthesizes the aggregate terminal for a multi-objective session once
2300    /// automated work is exhausted, and keeps `session().state()` in agreement.
2301    ///
2302    /// Synthesis applies ONLY to natural exhaustion (`Halt { NoEligibleAction }`).
2303    /// Every hard safety terminal — cycle/adaptation limits reaching here, and
2304    /// the budget/wall-time/cancellation reports that return earlier — is
2305    /// absolute and returned unchanged. Uses the existing terminal vocabulary:
2306    /// unresolved cases -> `AwaitHumanReview`; else a success -> `Complete`; else
2307    /// the untouched `Halt { NoEligibleAction }`.
2308    fn finalize_multi_objective_terminal(
2309        &mut self,
2310        terminal: DecisionLoopCommand,
2311        representative_success: Option<VerificationCase>,
2312        representative_unresolved: Option<VerificationCase>,
2313    ) -> DecisionLoopCommand {
2314        if !matches!(
2315            terminal,
2316            DecisionLoopCommand::Halt {
2317                reason: DecisionStopReason::NoEligibleAction
2318            }
2319        ) {
2320            return terminal;
2321        }
2322        if let Some(case) = representative_unresolved {
2323            self.session.finalize_human_review();
2324            DecisionLoopCommand::AwaitHumanReview { case }
2325        } else if let Some(case) = representative_success {
2326            self.session.finalize_objective_complete();
2327            DecisionLoopCommand::Complete { case }
2328        } else {
2329            terminal
2330        }
2331    }
2332
2333    fn run_failed(
2334        &mut self,
2335        bootstrap: Option<DecisionEvidenceReceipt>,
2336        completed_turns: Vec<StandardWebDecisionRuntimeTurn>,
2337        source: StandardWebDecisionRuntimeError,
2338        started_at: tokio::time::Instant,
2339    ) -> StandardWebDecisionRuntimeError {
2340        self.refresh_elapsed(started_at);
2341        StandardWebDecisionRuntimeError::RunFailed {
2342            receipt: Box::new(StandardWebDecisionFailureReceipt {
2343                bootstrap,
2344                completed_turns,
2345                usage: self.usage.clone(),
2346                transport: self.authority.request_accounting().dispatch_audit(),
2347            }),
2348            source: Box::new(source),
2349        }
2350    }
2351
2352    fn reserve_execution(
2353        &mut self,
2354        action_id: &str,
2355        stage: DecisionExecutionStage,
2356        execution_class: DecisionExecutionClass,
2357        started_at: tokio::time::Instant,
2358    ) -> Result<DecisionExecutionLimits, RuntimeLimitExceeded> {
2359        if let Some(limit) = self.wall_limit_if_reached(started_at) {
2360            return Err(limit);
2361        }
2362        // The transport-bound path is preserved byte-for-byte: request preflight,
2363        // then the semantic action-attempt guard, then the response allowance.
2364        // The local-knowledge path applies only the semantic guard — no request
2365        // preflight and no response-byte allowance, because it makes no request.
2366        match execution_class {
2367            DecisionExecutionClass::TransportBound => {
2368                self.sync_request_accounting();
2369                let preflight = self
2370                    .authority
2371                    .request_accounting()
2372                    .preflight(action_id, stage)?;
2373                self.reserve_action_attempt(action_id)?;
2374                Ok(DecisionExecutionLimits::new()
2375                    .with_max_response_body_bytes(preflight.remaining_response_bytes()))
2376            },
2377            DecisionExecutionClass::LocalKnowledge => {
2378                self.reserve_action_attempt(action_id)?;
2379                Ok(DecisionExecutionLimits::new())
2380            },
2381        }
2382    }
2383
2384    /// Enforces and reserves the semantic same-action-attempt guard, which
2385    /// applies to every execution class.
2386    fn reserve_action_attempt(&mut self, action_id: &str) -> Result<(), RuntimeLimitExceeded> {
2387        let attempts = self.usage.same_action_attempts(action_id);
2388        if attempts >= self.authority.budget().max_same_action_attempts() {
2389            return Err(RuntimeLimitExceeded::new(
2390                RuntimeBudgetDimension::SameActionAttempts,
2391                u64::from(self.authority.budget().max_same_action_attempts()),
2392                u64::from(attempts).saturating_add(1),
2393                Some(action_id.to_owned()),
2394            ));
2395        }
2396        self.usage.reserve_action_attempt(action_id);
2397        Ok(())
2398    }
2399
2400    fn validate_response_usage_evidence(
2401        &mut self,
2402        receipt: DecisionEvidenceReceipt,
2403        execution_class: DecisionExecutionClass,
2404    ) -> Result<DecisionEvidenceReceipt, StandardWebDecisionRuntimeError> {
2405        // HTTP response telemetry is a transport-bound invariant only. A
2406        // local-knowledge action performs no request and emits no response-body
2407        // observation, so the requirement does not apply to it.
2408        if execution_class == DecisionExecutionClass::LocalKnowledge {
2409            return Ok(receipt);
2410        }
2411        let response_body_bytes =
2412            HttpEvidencePredicate::RESPONSE_BODY_BYTES_OBSERVED.into_knowledge();
2413        let correlated: Vec<_> = receipt
2414            .evidence()
2415            .iter()
2416            .filter(|evidence| {
2417                evidence.source().correlation_id() == Some(receipt.case().id())
2418                    && evidence.predicate() == &response_body_bytes
2419            })
2420            .filter_map(|evidence| match evidence.value() {
2421                EvidenceValue::Unsigned(bytes) => Some(*bytes),
2422                _ => None,
2423            })
2424            .collect();
2425        if correlated.len() != 1 {
2426            return Err(StandardWebDecisionRuntimeError::ResponseUsageEvidence {
2427                case_id: receipt.case().id().to_owned(),
2428                predicate: HttpEvidencePredicate::RESPONSE_BODY_BYTES_OBSERVED.dotted(),
2429                observations: correlated.len(),
2430                receipt: Box::new(receipt),
2431            });
2432        }
2433        Ok(receipt)
2434    }
2435
2436    fn response_limit_if_exceeded(&mut self, action_id: &str) -> Option<RuntimeLimitExceeded> {
2437        self.sync_request_accounting();
2438        let observed = self.usage.response_bytes();
2439        (observed > self.authority.budget().max_response_bytes()).then(|| {
2440            RuntimeLimitExceeded::new(
2441                RuntimeBudgetDimension::ResponseBytes,
2442                self.authority.budget().max_response_bytes(),
2443                observed,
2444                Some(action_id.to_owned()),
2445            )
2446        })
2447    }
2448
2449    fn sync_request_accounting(&mut self) {
2450        self.usage
2451            .sync_request_accounting(self.authority.request_accounting().snapshot());
2452    }
2453
2454    fn refresh_elapsed(&mut self, started_at: tokio::time::Instant) {
2455        self.sync_request_accounting();
2456        self.usage.set_elapsed(started_at.elapsed());
2457    }
2458
2459    fn wall_limit_if_reached(
2460        &mut self,
2461        started_at: tokio::time::Instant,
2462    ) -> Option<RuntimeLimitExceeded> {
2463        self.refresh_elapsed(started_at);
2464        (started_at.elapsed() >= self.authority.budget().max_wall_time())
2465            .then(|| self.wall_limit(started_at))
2466    }
2467
2468    fn wall_limit(&mut self, started_at: tokio::time::Instant) -> RuntimeLimitExceeded {
2469        self.refresh_elapsed(started_at);
2470        RuntimeLimitExceeded::new(
2471            RuntimeBudgetDimension::WallTime,
2472            self.authority.budget().max_wall_time_ms(),
2473            self.usage
2474                .elapsed_ms()
2475                .max(self.authority.budget().max_wall_time_ms()),
2476            None,
2477        )
2478    }
2479
2480    fn limit_report(
2481        &mut self,
2482        bootstrap: Option<DecisionEvidenceReceipt>,
2483        turns: Vec<StandardWebDecisionRuntimeTurn>,
2484        limit: RuntimeLimitExceeded,
2485        started_at: tokio::time::Instant,
2486    ) -> StandardWebDecisionRunReport {
2487        self.limit_report_with_failure(bootstrap, turns, limit, None, started_at)
2488    }
2489
2490    fn limit_report_with_failure(
2491        &mut self,
2492        bootstrap: Option<DecisionEvidenceReceipt>,
2493        turns: Vec<StandardWebDecisionRuntimeTurn>,
2494        limit: RuntimeLimitExceeded,
2495        execution_failure: Option<DecisionExecutionFailureReceipt>,
2496        started_at: tokio::time::Instant,
2497    ) -> StandardWebDecisionRunReport {
2498        self.refresh_elapsed(started_at);
2499        self.session.halt_for_runtime_budget();
2500        StandardWebDecisionRunReport {
2501            bootstrap,
2502            turns,
2503            unverified_evidence: None,
2504            terminal: DecisionLoopCommand::Halt {
2505                reason: crate::DecisionStopReason::RuntimeBudgetLimit,
2506            },
2507            usage: self.usage.clone(),
2508            transport: self.authority.request_accounting().dispatch_audit(),
2509            limit_exceeded: Some(limit),
2510            execution_failure,
2511        }
2512    }
2513
2514    fn limit_report_with_unverified_evidence(
2515        &mut self,
2516        bootstrap: Option<DecisionEvidenceReceipt>,
2517        turns: Vec<StandardWebDecisionRuntimeTurn>,
2518        evidence: DecisionEvidenceReceipt,
2519        limit: RuntimeLimitExceeded,
2520        started_at: tokio::time::Instant,
2521    ) -> StandardWebDecisionRunReport {
2522        self.refresh_elapsed(started_at);
2523        self.session.halt_for_runtime_budget();
2524        StandardWebDecisionRunReport {
2525            bootstrap,
2526            turns,
2527            unverified_evidence: Some(evidence),
2528            terminal: DecisionLoopCommand::Halt {
2529                reason: crate::DecisionStopReason::RuntimeBudgetLimit,
2530            },
2531            usage: self.usage.clone(),
2532            transport: self.authority.request_accounting().dispatch_audit(),
2533            limit_exceeded: Some(limit),
2534            execution_failure: None,
2535        }
2536    }
2537
2538    fn cancellation_report(
2539        &mut self,
2540        bootstrap: Option<DecisionEvidenceReceipt>,
2541        turns: Vec<StandardWebDecisionRuntimeTurn>,
2542        unverified_evidence: Option<DecisionEvidenceReceipt>,
2543        started_at: tokio::time::Instant,
2544    ) -> StandardWebDecisionRunReport {
2545        self.refresh_elapsed(started_at);
2546        self.session.halt_for_host_cancellation();
2547        StandardWebDecisionRunReport {
2548            bootstrap,
2549            turns,
2550            unverified_evidence,
2551            terminal: DecisionLoopCommand::Halt {
2552                reason: crate::DecisionStopReason::CancelledByHost,
2553            },
2554            usage: self.usage.clone(),
2555            transport: self.authority.request_accounting().dispatch_audit(),
2556            limit_exceeded: None,
2557            execution_failure: None,
2558        }
2559    }
2560}
2561
2562enum RuntimeExecution<T> {
2563    Completed(T),
2564    Cancelled,
2565    WallTimeExceeded,
2566}
2567
2568async fn await_execution<F, T>(
2569    cancellation: &CancellationToken,
2570    deadline: Option<tokio::time::Instant>,
2571    execution: F,
2572) -> RuntimeExecution<T>
2573where
2574    F: Future<Output = T>,
2575{
2576    tokio::pin!(execution);
2577    match deadline {
2578        Some(deadline) => {
2579            tokio::select! {
2580                // A ready execution result wins so a receipt produced by an
2581                // already-completed evidence commit is never discarded. When
2582                // both stop signals are ready, explicit host cancellation is
2583                // more specific than the wall-time fallback.
2584                biased;
2585                result = &mut execution => RuntimeExecution::Completed(result),
2586                () = cancellation.cancelled() => RuntimeExecution::Cancelled,
2587                () = tokio::time::sleep_until(deadline) => RuntimeExecution::WallTimeExceeded,
2588            }
2589        },
2590        None => {
2591            tokio::select! {
2592                biased;
2593                result = &mut execution => RuntimeExecution::Completed(result),
2594                () = cancellation.cancelled() => RuntimeExecution::Cancelled,
2595            }
2596        },
2597    }
2598}
2599
2600fn execution_metadata(
2601    command: &DecisionLoopCommand,
2602) -> Result<(&str, DecisionExecutionStage), StandardWebDecisionRuntimeError> {
2603    match command {
2604        DecisionLoopCommand::ExecuteAction { case, .. } => {
2605            Ok((case.action_id(), DecisionExecutionStage::Passive))
2606        },
2607        DecisionLoopCommand::CollectActiveEvidence { case } => {
2608            Ok((case.action_id(), DecisionExecutionStage::Active))
2609        },
2610        DecisionLoopCommand::Replan
2611        | DecisionLoopCommand::Complete { .. }
2612        | DecisionLoopCommand::AwaitHumanReview { .. }
2613        | DecisionLoopCommand::Halt { .. } => {
2614            Err(StandardWebDecisionRuntimeError::ExecutionMetadataUnavailable)
2615        },
2616    }
2617}
2618
2619fn is_terminal(command: &DecisionLoopCommand) -> bool {
2620    matches!(
2621        command,
2622        DecisionLoopCommand::Complete { .. }
2623            | DecisionLoopCommand::AwaitHumanReview { .. }
2624            | DecisionLoopCommand::Halt { .. }
2625    )
2626}
2627
2628/// Records a representative case for the aggregate terminal when a continuation
2629/// rule suppressed this action. A suppressed `Success` is a completed objective;
2630/// a suppressed `Blocked` or active-inconclusive (`Unknown`/`NeedsReview`) is an
2631/// unresolved case pending human review. `FalsePositive`/`ConfirmedNegative`
2632/// (also carried on `Replan { suppress }` by the unchanged fallback) are
2633/// conclusive negatives and count as neither. First-in-dispatch-order wins.
2634fn classify_continuation_case(
2635    decision: &DecisionOutcomeReport,
2636    representative_success: &mut Option<VerificationCase>,
2637    representative_unresolved: &mut Option<VerificationCase>,
2638) {
2639    if !matches!(
2640        decision.adaptive().directive(),
2641        PipelineDirective::Replan {
2642            suppress_current_action: true
2643        }
2644    ) {
2645        return;
2646    }
2647    let report = decision.verification();
2648    match report.outcome().status() {
2649        OutcomeStatus::Success => {
2650            representative_success.get_or_insert_with(|| report.case().clone());
2651        },
2652        OutcomeStatus::Blocked => {
2653            representative_unresolved.get_or_insert_with(|| report.case().clone());
2654        },
2655        OutcomeStatus::Unknown | OutcomeStatus::NeedsReview
2656            if report.outcome().stage() == VerificationStage::Active =>
2657        {
2658            representative_unresolved.get_or_insert_with(|| report.case().clone());
2659        },
2660        _ => {},
2661    }
2662}
2663
2664/// Surface-B multi-objective continuation rules.
2665///
2666/// After an action reaches a terminal-worthy outcome, suppress it (via the
2667/// existing adaptation-ledger suppression carried by `Replan { suppress_current_
2668/// action: true }`) and replan, so the runtime can pursue another eligible
2669/// discovery objective instead of stopping at the first. Outcome classification
2670/// is never altered — only the follow-on directive. Passive inconclusive
2671/// outcomes still escalate through the unchanged fallback
2672/// (`AwaitActiveVerification`); false-positive / confirmed-negative also keep the
2673/// unchanged fallback (`Replan { suppress }`).
2674fn standard_web_continuation_rules() -> Result<Vec<AdaptationRule>, AdaptivePipelineError> {
2675    let suppress = PipelineDirective::Replan {
2676        suppress_current_action: true,
2677    };
2678    Ok(vec![
2679        AdaptationRule::new(
2680            "web.continue.success",
2681            OutcomeSelector::any_stage(BTreeSet::from([OutcomeStatus::Success]))?,
2682            700,
2683            None,
2684            suppress.clone(),
2685            "record the success and continue to any other eligible objective",
2686            u16::MAX,
2687        )?,
2688        AdaptationRule::new(
2689            "web.continue.blocked",
2690            OutcomeSelector::any_stage(BTreeSet::from([OutcomeStatus::Blocked]))?,
2691            700,
2692            None,
2693            suppress.clone(),
2694            "record the blocked outcome and continue to any other eligible objective",
2695            u16::MAX,
2696        )?,
2697        AdaptationRule::new(
2698            "web.continue.active-inconclusive",
2699            OutcomeSelector::new(
2700                BTreeSet::from([OutcomeStatus::Unknown, OutcomeStatus::NeedsReview]),
2701                BTreeSet::from([VerificationStage::Active]),
2702            )?,
2703            700,
2704            None,
2705            suppress,
2706            "record the inconclusive outcome after active verification and continue",
2707            u16::MAX,
2708        )?,
2709    ])
2710}
2711
2712#[cfg(feature = "authorization-review")]
2713fn resource_authorization_terminal_adaptation_rule() -> Result<AdaptationRule, AdaptivePipelineError>
2714{
2715    AdaptationRule::new(
2716        "web.authorization-review.stop-after-terminal-phase@1",
2717        OutcomeSelector::any_stage(BTreeSet::from([OutcomeStatus::Blocked]))?,
2718        1_000,
2719        Some(Expression::equals(
2720            KnowledgeLayer::Evidence,
2721            crate::web_actions::authorization_review_phase_terminal_predicate(),
2722            EvidenceValue::Boolean(true),
2723        )),
2724        PipelineDirective::Halt,
2725        "stop optional network work after authorization review defense, rate-limit, or incomplete transport evidence",
2726        1,
2727    )
2728}
2729
2730fn outcome_made_progress(
2731    previous_stage: DecisionExecutionStage,
2732    next_command: &DecisionLoopCommand,
2733    outcome: &DecisionOutcomeReport,
2734) -> bool {
2735    let hypothesis_changed = matches!(
2736        outcome.hypothesis_write(),
2737        Some(KnowledgeWrite::Inserted | KnowledgeWrite::Updated)
2738    );
2739    let escalated_to_active = previous_stage == DecisionExecutionStage::Passive
2740        && matches!(
2741            next_command,
2742            DecisionLoopCommand::CollectActiveEvidence { .. }
2743        );
2744    let conclusive = matches!(
2745        outcome.verification().outcome().status(),
2746        OutcomeStatus::Success | OutcomeStatus::FalsePositive | OutcomeStatus::ConfirmedNegative
2747    );
2748    // A suppression-driven replan is genuine forward progress: the source action
2749    // is newly added to the adaptation suppression set, so the automated
2750    // candidate set strictly shrinks. This is NOT true of arbitrary replans —
2751    // only of `Replan { suppress_current_action: true }`.
2752    let suppressed_source = matches!(
2753        outcome.adaptive().directive(),
2754        PipelineDirective::Replan {
2755            suppress_current_action: true
2756        }
2757    );
2758    hypothesis_changed || escalated_to_active || conclusive || suppressed_source
2759}
2760
2761#[cfg(test)]
2762#[path = "../tests/support/http_desynchronization_lab.rs"]
2763mod http_desynchronization_lab;
2764
2765#[cfg(test)]
2766#[path = "web_runtime_tests.rs"]
2767mod tests;