Skip to main content

termivar_scanner/web_runtime/
resource_authorization_runtime.rs

1//! Explicitly enabled four-view authorization differential orchestration.
2//!
3//! This child owns neither transport nor budget authority. It installs one
4//! executor under the parent [`WebAssessmentRuntime`], dispatches four fixed
5//! bodyless GET legs through isolated views of the shared broker, reduces each
6//! complete response through the transport-neutral authorization comparator,
7//! and retains only typed, redaction-safe receipts.
8
9use std::{
10    collections::{BTreeMap, BTreeSet},
11    fmt,
12    sync::{Arc, Mutex},
13};
14
15use async_trait::async_trait;
16use serde::Serialize;
17use sha2::{Digest, Sha256};
18use termivar_core::{
19    ConfidenceScore, EntityId, Evidence, EvidenceId, EvidenceKind, EvidenceSource, EvidenceValue,
20    KnowledgePredicate,
21};
22
23use super::{
24    assessment_defense::{
25        project_assessment_defense_signal, AssessmentDefenseBodyCoverage,
26        AssessmentDefenseProjectionContext, AssessmentDefenseSignal,
27    },
28    assessment_item::{
29        AssessmentCapabilityDescriptor, AssessmentItemProjectionError, AssessmentItemTarget,
30        AssessmentProjectionContext,
31    },
32};
33use crate::{
34    authorization_review::{
35        AuthorizationDifferentialError, AuthorizationDifferentialResult,
36        AuthorizationPrincipalExecutionHandoff, AuthorizationPrincipalPair,
37        AuthorizationPrincipalPairProof, AuthorizationResourceScopeId,
38        AuthorizationReviewBodyState, AuthorizationReviewMediaClass, AuthorizationReviewOutcome,
39        AuthorizationReviewPolicy, AuthorizationReviewPolicyId, AuthorizationReviewView,
40        AuthorizationReviewViewError, AuthorizationViewReceiptId, AuthorizationViewRole,
41    },
42    defense::DefenseState,
43    http_evidence::{AuthorizationResponseDefense, HttpRequestBroker, HttpRequestBrokerError},
44    DecisionActionExecutor, DecisionActionOrigin, DecisionExecutionRequest, DecisionExecutionStage,
45    DecisionExecutorError, DecisionExecutorRegistry, KnowledgeBase, RuntimeLimitExceeded,
46    TransportDispatchOutcome,
47};
48
49/// The one optional native action installed by Resource Authorization Review V1.
50pub const RESOURCE_AUTHORIZATION_REVIEW_ACTION_ID: &str =
51    "web.review.authorization.resource-differential";
52/// Stable AssessmentItem capability emitted only by exact positive truth.
53pub const RESOURCE_AUTHORIZATION_REVIEW_CAPABILITY_ID: &str =
54    "authorization.resource-cross-principal-equivalence@1";
55/// V1 selects exactly one operator-declared resource.
56pub const MAX_AUTHORIZATION_REVIEW_RESOURCES: usize = 1;
57/// V1 dispatches exactly four independently accounted request legs.
58pub const MAX_AUTHORIZATION_REVIEW_REQUESTS: usize = 4;
59/// The four request legs form one logical active verification.
60pub const MAX_AUTHORIZATION_REVIEW_ACTIVE_VERIFICATIONS: usize = 1;
61/// One exact extra planner cycle reserved only when the optional native action is installed.
62pub(super) const AUTHORIZATION_REVIEW_ACTION_CYCLE_ALLOWANCE: u32 = 1;
63
64const RESOURCE_AUTHORIZATION_EXECUTOR_ID: &str = "http.authorization-resource-review";
65const RESOURCE_AUTHORIZATION_EVIDENCE_NAMESPACE: &str = "web.authorization-review.transport";
66const RESOURCE_AUTHORIZATION_RECEIPT_DOMAIN: &[u8] =
67    b"security.authorization-review.response-receipt.v1\0";
68const RESOURCE_AUTHORIZATION_REQUEST_DOMAIN: &[u8] =
69    b"security.authorization-review.request-template.v1\0";
70#[cfg(feature = "secret-exposure-review")]
71const MAX_VALUE_FREE_AUTHORIZATION_PHASE_RECORDS: usize = 20;
72
73#[derive(Clone, Copy)]
74enum AuthorizationResponsePrivacy {
75    Historical,
76    #[cfg(feature = "secret-exposure-review")]
77    ValueFree,
78}
79
80impl AuthorizationResponsePrivacy {
81    const fn is_value_free(self) -> bool {
82        match self {
83            Self::Historical => false,
84            #[cfg(feature = "secret-exposure-review")]
85            Self::ValueFree => true,
86        }
87    }
88}
89
90pub(super) const AUTHORIZATION_DEFENSE_STATUS_PREDICATE: &str = "defense-status";
91pub(super) const AUTHORIZATION_DEFENSE_CHALLENGE_PREDICATE: &str = "defense-challenge";
92pub(super) const AUTHORIZATION_DEFENSE_RATE_LIMIT_PREDICATE: &str = "defense-rate-limit";
93pub(super) const AUTHORIZATION_DEFENSE_SCOPE_PREDICATE: &str = "defense-resource-scope";
94pub(super) const AUTHORIZATION_NO_RESPONSE_TERMINAL_PREDICATE: &str = "no-response-terminal";
95
96const RESOURCE_AUTHORIZATION_CAPABILITY: AssessmentCapabilityDescriptor =
97    AssessmentCapabilityDescriptor::differential_review(
98        RESOURCE_AUTHORIZATION_REVIEW_CAPABILITY_ID,
99        "Unexpected cross-principal resource equivalence observed",
100        "Authorization review",
101        "Under an operator-declared primary-only policy, two distinct authenticated principal contexts repeatedly received equivalent selected JSON resource representations.",
102        None,
103        900_000,
104        None,
105        "authorization.resource-policy-review@1",
106        "Review the intended resource-level authorization policy and verify server-side ownership, tenant, and role checks for the selected resource.",
107    );
108
109/// Redaction-safe audit embedded in the composed assessment report.
110#[derive(Clone, PartialEq, Eq, Serialize)]
111pub struct WebAssessmentAuthorizationAudit {
112    policy_id: AuthorizationReviewPolicyId,
113    selected_path_count: u8,
114    ignored_path_count: u8,
115    request_count: u8,
116    outcome: AuthorizationReviewOutcome,
117    primary_stable: Option<bool>,
118    peer_stable: Option<bool>,
119    cross_resources_equivalent: Option<bool>,
120    item_projected: bool,
121}
122
123impl WebAssessmentAuthorizationAudit {
124    /// Returns the stable policy identity; it contains no resource or credential material.
125    pub const fn policy_id(&self) -> AuthorizationReviewPolicyId {
126        self.policy_id
127    }
128
129    /// Returns the number of selected JSON Pointer paths without exposing them.
130    pub const fn selected_path_count(&self) -> u8 {
131        self.selected_path_count
132    }
133
134    /// Returns the number of ignored JSON Pointer paths without exposing them.
135    pub const fn ignored_path_count(&self) -> u8 {
136        self.ignored_path_count
137    }
138
139    /// Returns the exact number of broker-dispatched legs.
140    pub const fn request_count(&self) -> u8 {
141        self.request_count
142    }
143
144    /// Returns the typed, non-boolean differential outcome.
145    pub const fn outcome(&self) -> AuthorizationReviewOutcome {
146        self.outcome
147    }
148
149    /// Returns whether the two primary views were fully equivalent.
150    pub const fn primary_stable(&self) -> Option<bool> {
151        self.primary_stable
152    }
153
154    /// Returns whether the two peer views were fully equivalent.
155    pub const fn peer_stable(&self) -> Option<bool> {
156        self.peer_stable
157    }
158
159    /// Returns whether both cross-principal rounds had equal resource fingerprints.
160    pub const fn cross_resources_equivalent(&self) -> Option<bool> {
161        self.cross_resources_equivalent
162    }
163
164    /// Returns whether the common projection authority emitted the review item.
165    pub const fn item_projected(&self) -> bool {
166        self.item_projected
167    }
168
169    fn from_result(
170        policy: &AuthorizationReviewPolicy,
171        request_count: usize,
172        result: &AuthorizationDifferentialResult,
173    ) -> Self {
174        Self {
175            policy_id: result.policy_id(),
176            selected_path_count: u8::try_from(policy.selected_path_count()).unwrap_or(u8::MAX),
177            ignored_path_count: u8::try_from(policy.ignored_path_count()).unwrap_or(u8::MAX),
178            request_count: u8::try_from(request_count).unwrap_or(u8::MAX),
179            outcome: result.outcome(),
180            primary_stable: result.primary_stability().map(|value| value.all()),
181            peer_stable: result.peer_stability().map(|value| value.all()),
182            cross_resources_equivalent: result
183                .cross_candidate()
184                .zip(result.cross_replay())
185                .map(|(candidate, replay)| candidate.resources() && replay.resources()),
186            item_projected: result.outcome()
187                == AuthorizationReviewOutcome::StableCrossPrincipalEquivalence,
188        }
189    }
190
191    fn stopped(
192        policy: &AuthorizationReviewPolicy,
193        request_count: usize,
194        outcome: AuthorizationReviewOutcome,
195    ) -> Self {
196        Self {
197            policy_id: policy.policy_id(),
198            selected_path_count: u8::try_from(policy.selected_path_count()).unwrap_or(u8::MAX),
199            ignored_path_count: u8::try_from(policy.ignored_path_count()).unwrap_or(u8::MAX),
200            request_count: u8::try_from(request_count).unwrap_or(u8::MAX),
201            outcome,
202            primary_stable: None,
203            peer_stable: None,
204            cross_resources_equivalent: None,
205            item_projected: false,
206        }
207    }
208}
209
210impl fmt::Debug for WebAssessmentAuthorizationAudit {
211    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
212        formatter
213            .debug_struct("WebAssessmentAuthorizationAudit")
214            .field("policy_id", &self.policy_id)
215            .field("selected_path_count", &self.selected_path_count)
216            .field("ignored_path_count", &self.ignored_path_count)
217            .field("request_count", &self.request_count)
218            .field("outcome", &self.outcome)
219            .field("primary_stable", &self.primary_stable)
220            .field("peer_stable", &self.peer_stable)
221            .field(
222                "cross_resources_equivalent",
223                &self.cross_resources_equivalent,
224            )
225            .field("item_projected", &self.item_projected)
226            .finish()
227    }
228}
229
230/// Move-only runtime configuration consumed by the sole assessment root.
231pub(super) struct ResourceAuthorizationReviewConfig {
232    policy: AuthorizationReviewPolicy,
233    principals: AuthorizationPrincipalExecutionHandoff,
234    privacy: AuthorizationResponsePrivacy,
235}
236
237impl ResourceAuthorizationReviewConfig {
238    pub(super) fn new(
239        policy: AuthorizationReviewPolicy,
240        principals: AuthorizationPrincipalPair,
241    ) -> Self {
242        Self {
243            policy,
244            principals: principals.into_execution_handoff(),
245            privacy: AuthorizationResponsePrivacy::Historical,
246        }
247    }
248
249    /// Only the assessment's selected privacy boundary can prepare this mode.
250    /// It does not admit credentialed bodies to the anonymous detector.
251    #[cfg(feature = "secret-exposure-review")]
252    pub(super) fn with_value_free_response_privacy(mut self) -> Self {
253        self.privacy = AuthorizationResponsePrivacy::ValueFree;
254        self
255    }
256
257    pub(super) fn execution_resource(&self) -> &url::Url {
258        self.policy.execution_resource()
259    }
260}
261
262impl fmt::Debug for ResourceAuthorizationReviewConfig {
263    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
264        formatter.write_str("ResourceAuthorizationReviewConfig(<redacted>)")
265    }
266}
267
268/// Parent-installed executor binding for the one optional native action.
269///
270/// The binding owns the move-only principal material, but it does not own a
271/// registry, runner, broker, budget, authority, or report. Those remain under
272/// [`WebAssessmentRuntime`](super::WebAssessmentRuntime).
273pub(super) struct ResourceAuthorizationRuntimeBinding {
274    policy: Arc<AuthorizationReviewPolicy>,
275    proof: AuthorizationPrincipalPairProof,
276    subject: EntityId,
277    executor: Arc<ResourceAuthorizationDecisionExecutor>,
278}
279
280impl ResourceAuthorizationRuntimeBinding {
281    /// Builds the move-only child binding without creating transport, budget,
282    /// runner, or registry authority.
283    pub(super) fn new(
284        config: ResourceAuthorizationReviewConfig,
285        requests: HttpRequestBroker,
286        subject: EntityId,
287    ) -> Result<Self, ResourceAuthorizationRuntimeInvariantError> {
288        let ResourceAuthorizationReviewConfig {
289            policy,
290            principals,
291            privacy,
292        } = config;
293        let AuthorizationPrincipalExecutionHandoff {
294            primary_authorization,
295            peer_authorization,
296            proof,
297        } = principals;
298        let policy = Arc::new(policy);
299        let executor = Arc::new(ResourceAuthorizationDecisionExecutor {
300            requests,
301            policy: Arc::clone(&policy),
302            subject: subject.clone(),
303            primary_authorization,
304            peer_authorization,
305            privacy,
306            state: Mutex::new(ResourceAuthorizationExecutionState::default()),
307        });
308        Ok(Self {
309            policy,
310            proof,
311            subject,
312            executor,
313        })
314    }
315
316    /// Installs one executor and two routes into the registry already owned by
317    /// the parent [`StandardWebDecisionRuntime`](super::StandardWebDecisionRuntime).
318    pub(super) fn install_into_parent_registry(
319        &self,
320        registry: &mut DecisionExecutorRegistry,
321    ) -> Result<(), ResourceAuthorizationRuntimeInvariantError> {
322        let before = registry.len();
323        let registered: Arc<dyn DecisionActionExecutor> = self.executor.clone();
324        registry
325            .register(registered)
326            .map_err(|_| ResourceAuthorizationRuntimeInvariantError::Catalog)?;
327        for stage in [
328            DecisionExecutionStage::Passive,
329            DecisionExecutionStage::Active,
330        ] {
331            registry
332                .route_action(
333                    stage,
334                    RESOURCE_AUTHORIZATION_REVIEW_ACTION_ID,
335                    RESOURCE_AUTHORIZATION_EXECUTOR_ID,
336                )
337                .map_err(|_| ResourceAuthorizationRuntimeInvariantError::Catalog)?;
338        }
339        (registry.len() == before.saturating_add(1)
340            && registry.contains(RESOURCE_AUTHORIZATION_EXECUTOR_ID))
341        .then_some(())
342        .ok_or(ResourceAuthorizationRuntimeInvariantError::Catalog)
343    }
344
345    /// Finalizes already committed parent-runner evidence. This method performs
346    /// no I/O and creates no detached report or execution lifecycle.
347    pub(super) fn finalize(
348        self,
349        knowledge: &KnowledgeBase,
350        standard: &super::StandardWebDecisionRunReport,
351        expected_value_free: bool,
352        forced_outcome: Option<AuthorizationReviewOutcome>,
353        forced_runtime_limit: Option<RuntimeLimitExceeded>,
354    ) -> Result<ResourceAuthorizationRuntimeResult, ResourceAuthorizationRuntimeInvariantError>
355    {
356        if self.executor.privacy.is_value_free() != expected_value_free {
357            return Err(ResourceAuthorizationRuntimeInvariantError::Catalog);
358        }
359        let transport = standard.transport();
360        if transport.omitted_receipt_count() != 0 {
361            return Err(ResourceAuthorizationRuntimeInvariantError::Catalog);
362        }
363        let authorization_receipts = transport
364            .receipts()
365            .iter()
366            .filter(|receipt| receipt.action_id() == RESOURCE_AUTHORIZATION_REVIEW_ACTION_ID)
367            .collect::<Vec<_>>();
368        let request_count = authorization_receipts.len();
369        if request_count > MAX_AUTHORIZATION_REVIEW_REQUESTS {
370            return Err(ResourceAuthorizationRuntimeInvariantError::Catalog);
371        }
372        if !authorization_transport_prefix_is_valid(&authorization_receipts) {
373            return Err(ResourceAuthorizationRuntimeInvariantError::Catalog);
374        }
375        let state = self.executor.take_state()?;
376        #[cfg(feature = "secret-exposure-review")]
377        if self.executor.privacy.is_value_free() {
378            let parent_interrupted = forced_outcome == Some(AuthorizationReviewOutcome::Cancelled)
379                || (forced_outcome == Some(AuthorizationReviewOutcome::BudgetExhausted)
380                    && forced_runtime_limit.as_ref() == standard.limit_exceeded()
381                    && standard.limit_exceeded().is_some_and(|limit| {
382                        limit.dimension() == crate::RuntimeBudgetDimension::WallTime
383                    }));
384            validate_value_free_authorization_commits(
385                standard,
386                knowledge,
387                &self.policy,
388                &self.subject,
389                &state,
390                parent_interrupted,
391            )?;
392        }
393        let terminal = forced_outcome.or(state.terminal);
394        let runtime_limit = if forced_outcome.is_some() {
395            forced_runtime_limit
396        } else {
397            state.runtime_limit
398        };
399        if let Some(outcome) = terminal {
400            return Ok(ResourceAuthorizationRuntimeResult::Stopped {
401                audit: WebAssessmentAuthorizationAudit::stopped(
402                    &self.policy,
403                    request_count,
404                    outcome,
405                ),
406                runtime_limit,
407            });
408        }
409        let mut captured = state.captured;
410        let exact_roles = [
411            AuthorizationViewRole::PrimaryCandidate,
412            AuthorizationViewRole::PeerCandidate,
413            AuthorizationViewRole::PrimaryReplay,
414            AuthorizationViewRole::PeerReplay,
415        ]
416        .into_iter()
417        .collect::<BTreeSet<_>>();
418        let captured_roles = captured.keys().copied().collect::<BTreeSet<_>>();
419        if request_count == 0 && captured_roles.is_empty() {
420            return Ok(ResourceAuthorizationRuntimeResult::Stopped {
421                audit: WebAssessmentAuthorizationAudit::stopped(
422                    &self.policy,
423                    0,
424                    AuthorizationReviewOutcome::NotEligible,
425                ),
426                runtime_limit: None,
427            });
428        }
429        if request_count != MAX_AUTHORIZATION_REVIEW_REQUESTS || captured_roles != exact_roles {
430            return Ok(ResourceAuthorizationRuntimeResult::Stopped {
431                audit: WebAssessmentAuthorizationAudit::stopped(
432                    &self.policy,
433                    request_count,
434                    AuthorizationReviewOutcome::Incomplete,
435                ),
436                runtime_limit: None,
437            });
438        }
439        let primary_candidate = take_role(&mut captured, AuthorizationViewRole::PrimaryCandidate)?;
440        let peer_candidate = take_role(&mut captured, AuthorizationViewRole::PeerCandidate)?;
441        let primary_replay = take_role(&mut captured, AuthorizationViewRole::PrimaryReplay)?;
442        let peer_replay = take_role(&mut captured, AuthorizationViewRole::PeerReplay)?;
443        if !captured.is_empty()
444            || authorization_receipts.iter().any(|receipt| {
445                receipt.request_body_bytes() != 0
446                    || receipt.outcome() != TransportDispatchOutcome::Completed
447            })
448        {
449            return Err(ResourceAuthorizationRuntimeInvariantError::Catalog);
450        }
451        let result = AuthorizationDifferentialResult::compare(
452            &self.policy,
453            self.proof,
454            [
455                &primary_candidate.view,
456                &peer_candidate.view,
457                &primary_replay.view,
458                &peer_replay.view,
459            ],
460        )?;
461        let primary_evidence_ids = vec![
462            primary_candidate.evidence_id.clone(),
463            primary_replay.evidence_id.clone(),
464        ];
465        let peer_evidence_ids = vec![
466            peer_candidate.evidence_id.clone(),
467            peer_replay.evidence_id.clone(),
468        ];
469        let unique = primary_evidence_ids
470            .iter()
471            .chain(peer_evidence_ids.iter())
472            .collect::<BTreeSet<_>>();
473        if unique.len() != MAX_AUTHORIZATION_REVIEW_REQUESTS
474            || unique
475                .iter()
476                .any(|evidence_id| knowledge.evidence(evidence_id).is_none())
477        {
478            return Err(ResourceAuthorizationRuntimeInvariantError::Catalog);
479        }
480        let audit =
481            WebAssessmentAuthorizationAudit::from_result(&self.policy, request_count, &result);
482        Ok(ResourceAuthorizationRuntimeResult::Complete(Box::new(
483            CommittedResourceAuthorizationReview {
484                subject: self.subject,
485                policy_id: self.policy.policy_id(),
486                resource_scope_id: self.policy.resource_scope_id(),
487                result,
488                primary_evidence_ids,
489                peer_evidence_ids,
490                audit,
491            },
492        )))
493    }
494}
495
496fn authorization_transport_prefix_is_valid(receipts: &[&crate::TransportDispatchReceipt]) -> bool {
497    receipts.iter().enumerate().all(|(index, receipt)| {
498        let expected_stage = if index == 2 {
499            DecisionExecutionStage::Active
500        } else {
501            DecisionExecutionStage::Passive
502        };
503        let expected_origin = (expected_stage == DecisionExecutionStage::Passive)
504            .then_some(DecisionActionOrigin::Planned);
505        receipt.stage() == expected_stage && receipt.origin() == expected_origin
506    })
507}
508
509/// A staged reduction is not authority. Require the identical, bounded batch
510/// in the real parent runner's committed write set before any projection.
511#[cfg(feature = "secret-exposure-review")]
512fn validate_value_free_authorization_commits(
513    standard: &super::StandardWebDecisionRunReport,
514    knowledge: &KnowledgeBase,
515    policy: &AuthorizationReviewPolicy,
516    subject: &EntityId,
517    state: &ResourceAuthorizationExecutionState,
518    parent_interrupted: bool,
519) -> Result<(), ResourceAuthorizationRuntimeInvariantError> {
520    use crate::{KnowledgeWrite, StandardWebDecisionRuntimeTurn};
521    let invalid = || ResourceAuthorizationRuntimeInvariantError::Catalog;
522    let receipts = standard
523        .turns()
524        .iter()
525        .filter_map(|turn| match turn {
526            StandardWebDecisionRuntimeTurn::Outcome { evidence, .. } => Some(evidence.as_ref()),
527            StandardWebDecisionRuntimeTurn::Planning(_) => None,
528        })
529        .chain(standard.unverified_evidence())
530        .filter(|receipt| {
531            receipt.case().action_id() == RESOURCE_AUTHORIZATION_REVIEW_ACTION_ID
532                || receipt.executor_id() == RESOURCE_AUTHORIZATION_EXECUTOR_ID
533        })
534        .collect::<Vec<_>>();
535    // Parent cancellation or its exact wall-time limit may drop the current
536    // executor future before its proposed batch is committed. Such a tail
537    // cannot support any comparison result: the caller returns only a forced
538    // stopped audit. Other budget limits keep the strict committed-receipt path.
539    // Validate every completed receipt without promoting staged facts to authority.
540    let staged = state.staged_value_free_batches.len();
541    if receipts.len() > 2
542        || staged > 2
543        || (if parent_interrupted {
544            staged < receipts.len() || staged > receipts.len().saturating_add(1)
545        } else {
546            receipts.len() != staged
547        })
548        || (!parent_interrupted && receipts.is_empty() && state.terminal.is_some())
549        || (state.runtime_limit.is_some()
550            != (state.terminal == Some(AuthorizationReviewOutcome::BudgetExhausted)))
551    {
552        return Err(invalid());
553    }
554    let wire = standard
555        .transport()
556        .receipts()
557        .iter()
558        .filter(|receipt| receipt.action_id() == RESOURCE_AUTHORIZATION_REVIEW_ACTION_ID)
559        .collect::<Vec<_>>();
560    let roles = [
561        AuthorizationViewRole::PrimaryCandidate,
562        AuthorizationViewRole::PeerCandidate,
563        AuthorizationViewRole::PrimaryReplay,
564        AuthorizationViewRole::PeerReplay,
565    ];
566    if state.captured.len() > roles.len()
567        || wire.len() < state.captured.len()
568        || wire.len() > state.captured.len().saturating_add(1)
569        || !roles[..state.captured.len()]
570            .iter()
571            .all(|role| state.captured.contains_key(role))
572        || (wire.len() > state.captured.len()
573            && ((!parent_interrupted && state.terminal.is_none())
574                || wire.last().is_some_and(|receipt| {
575                    receipt.outcome() == TransportDispatchOutcome::Completed
576                })))
577    {
578        return Err(invalid());
579    }
580    let mut committed_ids = BTreeSet::new();
581    let mut committed_roles = BTreeSet::new();
582    let mut case_id = None;
583    for (index, receipt) in receipts.iter().enumerate() {
584        let stage = if index == 0 {
585            DecisionExecutionStage::Passive
586        } else {
587            DecisionExecutionStage::Active
588        };
589        let case = receipt.case();
590        if receipt.stage() != stage
591            || receipt.executor_id() != RESOURCE_AUTHORIZATION_EXECUTOR_ID
592            || case.action_id() != RESOURCE_AUTHORIZATION_REVIEW_ACTION_ID
593            || case.subject() != subject
594            || case.applies_hypothesis_transition()
595            || case.payload_strategy().is_some()
596            || case_id.is_some_and(|previous| previous != case.id())
597            || receipt.evidence().len() != receipt.writes().len()
598            || receipt.evidence().len() > MAX_VALUE_FREE_AUTHORIZATION_PHASE_RECORDS
599            || receipt.evidence()
600                != state
601                    .staged_value_free_batches
602                    .get(&stage)
603                    .ok_or_else(invalid)?
604                    .as_slice()
605        {
606            return Err(invalid());
607        }
608        case_id = Some(case.id());
609        for (item, write) in receipt.write_set() {
610            let predicate = item.predicate();
611            let allowed = if predicate.namespace() == RESOURCE_AUTHORIZATION_EVIDENCE_NAMESPACE {
612                matches!(
613                    predicate.name(),
614                    "leg"
615                        | "leg-transport"
616                        | "phase-terminal"
617                        | "no-response-terminal"
618                        | "defense-status"
619                        | "defense-challenge"
620                        | "defense-rate-limit"
621                        | "defense-resource-scope"
622                )
623            } else if predicate.namespace()
624                == super::assessment_defense::ASSESSMENT_DEFENSE_NAMESPACE
625            {
626                matches!(
627                    predicate.name(),
628                    "observation_v1" | "body_coverage" | "posture" | "challenge" | "rate_limit"
629                )
630            } else {
631                predicate
632                    == &termivar_core::HttpEvidencePredicate::RESPONSE_BODY_BYTES_OBSERVED
633                        .into_knowledge()
634                    || predicate
635                        == &crate::web_actions::native_web_review_response_marker_predicate()
636            };
637            if !allowed
638                || !matches!(write, KnowledgeWrite::Inserted | KnowledgeWrite::Unchanged)
639                || !committed_ids.insert(item.id().clone())
640                || item.subject() != subject
641                || item.source().component() != RESOURCE_AUTHORIZATION_EXECUTOR_ID
642                || item.source().correlation_id() != Some(case.id())
643                || item.reliability() != ConfidenceScore::MAX
644                || knowledge.evidence(item.id()).as_ref() != Some(item)
645            {
646                return Err(invalid());
647            }
648        }
649        // Reuse the existing strict, transport-free defense receipt reader;
650        // do not make a second decoder or trust arbitrary web.defense rows.
651        super::assessment_defense::CommittedAssessmentDefenseLedger::default()
652            .ingest_receipt(receipt, knowledge, true)
653            .map_err(|_| invalid())?;
654        let mut phase_bytes = 0_u64;
655        let mut phase_roles = 0;
656        for role in ResourceAuthorizationDecisionExecutor::roles_for_stage(stage) {
657            let Some(leg) = state.captured.get(&role) else {
658                continue;
659            };
660            let metadata = leg.value_free_transport.as_ref().ok_or_else(invalid)?;
661            let root = receipt
662                .evidence()
663                .iter()
664                .find(|item| item.id() == &metadata.evidence_id)
665                .ok_or_else(invalid)?;
666            let classification = receipt
667                .evidence()
668                .iter()
669                .find(|item| item.id() == &leg.evidence_id)
670                .ok_or_else(invalid)?;
671            let root_predicate = KnowledgePredicate::new(
672                RESOURCE_AUTHORIZATION_EVIDENCE_NAMESPACE,
673                "leg-transport",
674            )?;
675            let leg_predicate =
676                KnowledgePredicate::new(RESOURCE_AUTHORIZATION_EVIDENCE_NAMESPACE, "leg")?;
677            let expected_classification = vec![
678                format!("role={}", role_name(role)),
679                format!("policy={}", policy.policy_id()),
680                format!("resource={}", policy.resource_scope_id()),
681                format!(
682                    "request={}",
683                    encode_digest(request_template_digest(policy, role))
684                ),
685                format!("status={}", leg.view.status().ok_or_else(invalid)?),
686                format!("media={}", media_name(leg.view.media_class())),
687                format!("state={}", state_name(leg.view.state())),
688            ];
689            let derivation = classification.origin().derivation().ok_or_else(invalid)?;
690            let wire_index = roles
691                .iter()
692                .position(|candidate| *candidate == role)
693                .ok_or_else(invalid)?;
694            let transport = wire.get(wire_index).ok_or_else(invalid)?;
695            if !committed_roles.insert(role)
696                || receipt.evidence().get(phase_roles * 2).map(Evidence::id) != Some(root.id())
697                || receipt
698                    .evidence()
699                    .get(phase_roles * 2 + 1)
700                    .map(Evidence::id)
701                    != Some(classification.id())
702                || leg.view.role() != role
703                || root.predicate() != &root_predicate
704                || root.kind()
705                    != &EvidenceKind::Custom("authorization-review-leg-transport".to_owned())
706                || !root.origin().is_direct()
707                || root.source().method() != "bounded-json-transport"
708                || root.value()
709                    != &EvidenceValue::TextList(value_free_transport_values(
710                        role, &leg.view, metadata,
711                    ))
712                || classification.predicate() != &leg_predicate
713                || classification.kind()
714                    != &EvidenceKind::Custom("authorization-review-leg".to_owned())
715                || classification.source().method() != "bounded-json-view"
716                || classification.value() != &EvidenceValue::TextList(expected_classification)
717                || derivation.parents() != [root.id().clone()]
718                || derivation.algorithm().name() != "web.authorization-review.value-free-leg"
719                || derivation.algorithm().version() != 1
720                || transport.request_body_bytes() != 0
721                || metadata.response_bytes > transport.response_bytes()
722                || (metadata.complete
723                    && !metadata.truncated
724                    && metadata.response_bytes != transport.response_bytes())
725                || (leg.view.state() == AuthorizationReviewBodyState::CompleteJson
726                    && (!metadata.complete
727                        || metadata.truncated
728                        || metadata.defense != AuthorizationResponseDefense::Clear))
729            {
730                return Err(invalid());
731            }
732            phase_bytes = phase_bytes
733                .checked_add(metadata.response_bytes)
734                .ok_or_else(invalid)?;
735            phase_roles += 1;
736        }
737        // Join the last observed role to the existing phase defense facts.
738        // A stable JSON view must not hide a contradictory rate/challenge row.
739        let last_leg = ResourceAuthorizationDecisionExecutor::roles_for_stage(stage)
740            .into_iter()
741            .filter_map(|role| state.captured.get(&role))
742            .next_back();
743        let defense_names = [
744            AUTHORIZATION_DEFENSE_STATUS_PREDICATE,
745            AUTHORIZATION_DEFENSE_CHALLENGE_PREDICATE,
746            AUTHORIZATION_DEFENSE_RATE_LIMIT_PREDICATE,
747            AUTHORIZATION_DEFENSE_SCOPE_PREDICATE,
748        ];
749        if let Some(last_leg) = last_leg {
750            let metadata = last_leg.value_free_transport.as_ref().ok_or_else(invalid)?;
751            let values = [
752                EvidenceValue::Unsigned(u64::from(last_leg.view.status().ok_or_else(invalid)?)),
753                EvidenceValue::Boolean(metadata.defense == AuthorizationResponseDefense::Challenge),
754                EvidenceValue::Boolean(
755                    metadata.defense == AuthorizationResponseDefense::RateLimited,
756                ),
757                EvidenceValue::Text(policy.resource_scope_id().to_wire()),
758            ];
759            for (name, value) in defense_names.into_iter().zip(values) {
760                let item = authorization_receipt_fact(
761                    receipt,
762                    &KnowledgePredicate::new(RESOURCE_AUTHORIZATION_EVIDENCE_NAMESPACE, name)?,
763                )?;
764                if item.kind()
765                    != &EvidenceKind::Custom("authorization-review-defense-base".to_owned())
766                    || !item.origin().is_direct()
767                    || item.source().method() != "defense-base"
768                    || item.value() != &value
769                {
770                    return Err(invalid());
771                }
772            }
773        } else if receipt.evidence().iter().any(|item| {
774            item.predicate().namespace() == RESOURCE_AUTHORIZATION_EVIDENCE_NAMESPACE
775                && defense_names.contains(&item.predicate().name())
776        }) {
777            return Err(invalid());
778        }
779        for name in ["leg", "leg-transport"] {
780            if receipt
781                .evidence()
782                .iter()
783                .filter(|item| {
784                    item.predicate().namespace() == RESOURCE_AUTHORIZATION_EVIDENCE_NAMESPACE
785                        && item.predicate().name() == name
786                })
787                .count()
788                != phase_roles
789            {
790                return Err(invalid());
791            }
792        }
793        let bytes = authorization_receipt_fact(
794            receipt,
795            &termivar_core::HttpEvidencePredicate::RESPONSE_BODY_BYTES_OBSERVED.into_knowledge(),
796        )?;
797        if bytes.kind() != &EvidenceKind::Content
798            || !bytes.origin().is_direct()
799            || bytes.source().method() != "response-body-size"
800            || bytes.value() != &EvidenceValue::Unsigned(phase_bytes)
801        {
802            return Err(invalid());
803        }
804        let terminal = authorization_receipt_fact(
805            receipt,
806            &crate::web_actions::authorization_review_phase_terminal_predicate(),
807        )?;
808        let no_response = authorization_receipt_fact(
809            receipt,
810            &KnowledgePredicate::new(
811                RESOURCE_AUTHORIZATION_EVIDENCE_NAMESPACE,
812                AUTHORIZATION_NO_RESPONSE_TERMINAL_PREDICATE,
813            )?,
814        )?;
815        for item in [terminal, no_response] {
816            if item.kind() != &EvidenceKind::Custom("authorization-review-phase".to_owned())
817                || !item.origin().is_direct()
818                || item.source().method() != "phase-terminal"
819                || !matches!(item.value(), EvidenceValue::Boolean(_))
820            {
821                return Err(invalid());
822            }
823        }
824        let is_terminal = terminal.value() == &EvidenceValue::Boolean(true);
825        if (is_terminal && (state.terminal.is_none() || index + 1 != receipts.len()))
826            || (!parent_interrupted
827                && index + 1 == receipts.len()
828                && is_terminal != state.terminal.is_some())
829            || (!is_terminal && phase_roles != 2)
830            || no_response.value() != &EvidenceValue::Boolean(is_terminal && phase_roles == 0)
831        {
832            return Err(invalid());
833        }
834        let observed_terminal =
835            last_leg.and_then(|leg| terminal_outcome_for_state(leg.view.state()));
836        if is_terminal {
837            if let Some(observed) = observed_terminal {
838                if state.terminal != Some(observed) {
839                    return Err(invalid());
840                }
841            } else if phase_roles >= 2
842                || !matches!(
843                    state.terminal,
844                    Some(
845                        AuthorizationReviewOutcome::Incomplete
846                            | AuthorizationReviewOutcome::BudgetExhausted
847                    )
848                )
849            {
850                // An absent/failed next role is not a received rate-limit,
851                // challenge, or malformed-body observation. Forced host stops
852                // are supplied separately after this committed-state proof.
853                return Err(invalid());
854            }
855        } else if observed_terminal.is_some() {
856            return Err(invalid());
857        }
858        let marker = crate::web_actions::native_web_review_response_marker_predicate();
859        let markers = receipt
860            .evidence()
861            .iter()
862            .filter(|item| item.predicate() == &marker)
863            .collect::<Vec<_>>();
864        if stage == DecisionExecutionStage::Active && !is_terminal {
865            if markers.len() != 1
866                || !markers[0].origin().is_direct()
867                || markers[0].kind()
868                    != &EvidenceKind::Custom("native-web-review-response".to_owned())
869                || markers[0].source().method() != "pair-complete"
870                || markers[0].value() != &EvidenceValue::Boolean(true)
871            {
872                return Err(invalid());
873            }
874        } else if !markers.is_empty() {
875            return Err(invalid());
876        }
877    }
878    if committed_roles.len() != state.captured.len() && !parent_interrupted {
879        return Err(invalid());
880    }
881    Ok(())
882}
883
884#[cfg(feature = "secret-exposure-review")]
885fn authorization_receipt_fact<'a>(
886    receipt: &'a crate::DecisionEvidenceReceipt,
887    predicate: &KnowledgePredicate,
888) -> Result<&'a Evidence, ResourceAuthorizationRuntimeInvariantError> {
889    let mut matches = receipt
890        .evidence()
891        .iter()
892        .filter(|item| item.predicate() == predicate);
893    let item = matches
894        .next()
895        .ok_or(ResourceAuthorizationRuntimeInvariantError::Catalog)?;
896    if matches.next().is_some() {
897        return Err(ResourceAuthorizationRuntimeInvariantError::Catalog);
898    }
899    Ok(item)
900}
901
902impl fmt::Debug for ResourceAuthorizationRuntimeBinding {
903    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
904        formatter.write_str("ResourceAuthorizationRuntimeBinding(<redacted>)")
905    }
906}
907
908/// Internal composition failures are code-contract failures, not target behavior.
909#[derive(Debug, thiserror::Error)]
910pub(super) enum ResourceAuthorizationRuntimeInvariantError {
911    #[error("authorization review execution catalog violated its closed contract")]
912    Catalog,
913    #[error("authorization review view reduction failed")]
914    View(#[from] AuthorizationReviewViewError),
915    #[error("authorization review differential contract failed")]
916    Differential(#[from] AuthorizationDifferentialError),
917    #[error("authorization review evidence construction failed")]
918    Evidence(#[from] termivar_core::ReasoningModelError),
919    #[error("authorization review evidence commit failed")]
920    Knowledge(#[from] crate::KnowledgeBaseError),
921}
922
923pub(super) enum ResourceAuthorizationRuntimeResult {
924    Complete(Box<CommittedResourceAuthorizationReview>),
925    Stopped {
926        audit: WebAssessmentAuthorizationAudit,
927        runtime_limit: Option<RuntimeLimitExceeded>,
928    },
929}
930
931/// Exact committed comparison truth consumed by the common item projection.
932pub(super) struct CommittedResourceAuthorizationReview {
933    subject: EntityId,
934    policy_id: AuthorizationReviewPolicyId,
935    resource_scope_id: AuthorizationResourceScopeId,
936    result: AuthorizationDifferentialResult,
937    primary_evidence_ids: Vec<EvidenceId>,
938    peer_evidence_ids: Vec<EvidenceId>,
939    audit: WebAssessmentAuthorizationAudit,
940}
941
942impl CommittedResourceAuthorizationReview {
943    pub(super) const fn outcome(&self) -> AuthorizationReviewOutcome {
944        self.result.outcome()
945    }
946
947    pub(super) const fn audit(&self) -> &WebAssessmentAuthorizationAudit {
948        &self.audit
949    }
950}
951
952impl fmt::Debug for CommittedResourceAuthorizationReview {
953    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
954        formatter
955            .debug_struct("CommittedResourceAuthorizationReview")
956            .field("subject", &"<pseudonymous>")
957            .field("policy_id", &self.policy_id)
958            .field("resource_scope_id", &self.resource_scope_id)
959            .field("outcome", &self.result.outcome())
960            .field("evidence_count", &4)
961            .field("audit", &self.audit)
962            .finish()
963    }
964}
965
966struct CapturedAuthorizationLeg {
967    view: AuthorizationReviewView,
968    evidence_id: EvidenceId,
969    #[cfg(feature = "secret-exposure-review")]
970    value_free_transport: Option<ValueFreeAuthorizationTransport>,
971}
972
973#[cfg(feature = "secret-exposure-review")]
974struct ValueFreeAuthorizationTransport {
975    evidence_id: EvidenceId,
976    response_bytes: u64,
977    complete: bool,
978    truncated: bool,
979    defense: AuthorizationResponseDefense,
980}
981
982#[derive(Default)]
983struct ResourceAuthorizationExecutionState {
984    captured: BTreeMap<AuthorizationViewRole, CapturedAuthorizationLeg>,
985    terminal: Option<AuthorizationReviewOutcome>,
986    runtime_limit: Option<RuntimeLimitExceeded>,
987    // Bounded, value-free publication proposals, not committed authority.
988    // Only the selected path clones them, at most two ordinary parent phases.
989    #[cfg(feature = "secret-exposure-review")]
990    staged_value_free_batches: BTreeMap<DecisionExecutionStage, Vec<Evidence>>,
991}
992
993struct ResourceAuthorizationDecisionExecutor {
994    requests: HttpRequestBroker,
995    policy: Arc<AuthorizationReviewPolicy>,
996    subject: EntityId,
997    primary_authorization: String,
998    peer_authorization: String,
999    privacy: AuthorizationResponsePrivacy,
1000    state: Mutex<ResourceAuthorizationExecutionState>,
1001}
1002
1003impl ResourceAuthorizationDecisionExecutor {
1004    fn roles_for_stage(stage: DecisionExecutionStage) -> [AuthorizationViewRole; 2] {
1005        match stage {
1006            DecisionExecutionStage::Passive => [
1007                AuthorizationViewRole::PrimaryCandidate,
1008                AuthorizationViewRole::PeerCandidate,
1009            ],
1010            DecisionExecutionStage::Active => [
1011                AuthorizationViewRole::PrimaryReplay,
1012                AuthorizationViewRole::PeerReplay,
1013            ],
1014        }
1015    }
1016
1017    fn authorization_for(&self, role: AuthorizationViewRole) -> &str {
1018        match role {
1019            AuthorizationViewRole::PrimaryCandidate | AuthorizationViewRole::PrimaryReplay => {
1020                &self.primary_authorization
1021            },
1022            AuthorizationViewRole::PeerCandidate | AuthorizationViewRole::PeerReplay => {
1023                &self.peer_authorization
1024            },
1025        }
1026    }
1027
1028    fn take_state(
1029        &self,
1030    ) -> Result<ResourceAuthorizationExecutionState, ResourceAuthorizationRuntimeInvariantError>
1031    {
1032        let mut state = self
1033            .state
1034            .lock()
1035            .map_err(|_| ResourceAuthorizationRuntimeInvariantError::Catalog)?;
1036        Ok(std::mem::take(&mut *state))
1037    }
1038
1039    fn phase_is_ready(&self, stage: DecisionExecutionStage) -> Result<bool, DecisionExecutorError> {
1040        let state = self.state.lock().map_err(|_| {
1041            DecisionExecutorError::new("authorization capture state is unavailable")
1042        })?;
1043        let expected = match stage {
1044            DecisionExecutionStage::Passive => {
1045                state.captured.is_empty() && state.terminal.is_none()
1046            },
1047            DecisionExecutionStage::Active => {
1048                state.terminal.is_none()
1049                    && state
1050                        .captured
1051                        .contains_key(&AuthorizationViewRole::PrimaryCandidate)
1052                    && state
1053                        .captured
1054                        .contains_key(&AuthorizationViewRole::PeerCandidate)
1055                    && !state
1056                        .captured
1057                        .contains_key(&AuthorizationViewRole::PrimaryReplay)
1058                    && !state
1059                        .captured
1060                        .contains_key(&AuthorizationViewRole::PeerReplay)
1061            },
1062        };
1063        Ok(expected)
1064    }
1065
1066    fn commit_leg(
1067        &self,
1068        role: AuthorizationViewRole,
1069        leg: CapturedAuthorizationLeg,
1070    ) -> Result<(), DecisionExecutorError> {
1071        let replaced = self
1072            .state
1073            .lock()
1074            .map_err(|_| DecisionExecutorError::new("authorization capture state is unavailable"))?
1075            .captured
1076            .insert(role, leg);
1077        if replaced.is_some() {
1078            return Err(DecisionExecutorError::new(
1079                "authorization review attempted a duplicate leg",
1080            ));
1081        }
1082        Ok(())
1083    }
1084
1085    fn stop(&self, outcome: AuthorizationReviewOutcome) -> Result<(), DecisionExecutorError> {
1086        let mut state = self.state.lock().map_err(|_| {
1087            DecisionExecutorError::new("authorization capture state is unavailable")
1088        })?;
1089        if state.terminal.replace(outcome).is_some() {
1090            return Err(DecisionExecutorError::new(
1091                "authorization review attempted a duplicate terminal state",
1092            ));
1093        }
1094        Ok(())
1095    }
1096
1097    fn stop_at_runtime_limit(
1098        &self,
1099        limit: RuntimeLimitExceeded,
1100    ) -> Result<(), DecisionExecutorError> {
1101        let mut state = self.state.lock().map_err(|_| {
1102            DecisionExecutorError::new("authorization capture state is unavailable")
1103        })?;
1104        if state
1105            .terminal
1106            .replace(AuthorizationReviewOutcome::BudgetExhausted)
1107            .is_some()
1108            || state.runtime_limit.replace(limit).is_some()
1109        {
1110            return Err(DecisionExecutorError::new(
1111                "authorization review attempted a duplicate terminal state",
1112            ));
1113        }
1114        Ok(())
1115    }
1116}
1117
1118impl Drop for ResourceAuthorizationDecisionExecutor {
1119    fn drop(&mut self) {
1120        self.primary_authorization.clear();
1121        self.peer_authorization.clear();
1122    }
1123}
1124
1125#[async_trait]
1126impl DecisionActionExecutor for ResourceAuthorizationDecisionExecutor {
1127    fn id(&self) -> &str {
1128        RESOURCE_AUTHORIZATION_EXECUTOR_ID
1129    }
1130
1131    async fn execute(
1132        &self,
1133        request: &DecisionExecutionRequest,
1134    ) -> Result<Vec<Evidence>, DecisionExecutorError> {
1135        if request.case().action_id() != RESOURCE_AUTHORIZATION_REVIEW_ACTION_ID
1136            || request.case().subject() != &self.subject
1137            || request.case().applies_hypothesis_transition()
1138            || request.case().payload_strategy().is_some()
1139            || request.delay_ms().is_some()
1140            || (request.stage() == DecisionExecutionStage::Passive && request.origin().is_none())
1141            || (request.stage() == DecisionExecutionStage::Active && request.origin().is_some())
1142            || !self.phase_is_ready(request.stage())?
1143        {
1144            return Err(DecisionExecutorError::new(
1145                "authorization executor request violated its closed route contract",
1146            ));
1147        }
1148
1149        let roles = Self::roles_for_stage(request.stage());
1150        let mut evidence = Vec::new();
1151        let mut response_bytes = 0_u64;
1152        let mut last_defense = None;
1153        // Each role receives a fresh pool but retains the parent's exact policy
1154        // and accounting broker. Cookie, redirect, retry, and connection-bound
1155        // authentication state cannot cross roles.
1156        for role in roles {
1157            let isolated = match self.requests.isolated_authorization_review() {
1158                Ok(isolated) => isolated,
1159                Err(_) => {
1160                    self.stop(AuthorizationReviewOutcome::Incomplete)?;
1161                    break;
1162                },
1163            };
1164            let transport_stage = if role == AuthorizationViewRole::PrimaryReplay {
1165                DecisionExecutionStage::Active
1166            } else {
1167                DecisionExecutionStage::Passive
1168            };
1169            let origin = (transport_stage == DecisionExecutionStage::Passive)
1170                .then_some(DecisionActionOrigin::Planned);
1171            let response = isolated
1172                .collect_authorized_json_get_for_runtime(
1173                    RESOURCE_AUTHORIZATION_REVIEW_ACTION_ID,
1174                    transport_stage,
1175                    origin,
1176                    request.limits(),
1177                    self.policy.execution_resource(),
1178                    self.authorization_for(role),
1179                )
1180                .await;
1181            let response = match response {
1182                Ok(response) => response,
1183                Err(HttpRequestBrokerError::RuntimeLimit(limit)) => {
1184                    self.stop_at_runtime_limit(limit)?;
1185                    break;
1186                },
1187                Err(HttpRequestBrokerError::Http(_)) => {
1188                    self.stop(AuthorizationReviewOutcome::Incomplete)?;
1189                    break;
1190                },
1191            };
1192            response_bytes = response_bytes
1193                .saturating_add(u64::try_from(response.body().len()).unwrap_or(u64::MAX));
1194            let captured = capture_response(
1195                &self.policy,
1196                &self.subject,
1197                request.case().id(),
1198                role,
1199                response,
1200                self.privacy,
1201            )?;
1202            last_defense = Some(captured.defense);
1203            let terminal = terminal_outcome_for_state(captured.leg.view.state());
1204            evidence.extend(captured.evidence);
1205            self.commit_leg(role, captured.leg)?;
1206            if let Some(outcome) = terminal {
1207                self.stop(outcome)?;
1208                break;
1209            }
1210        }
1211        let terminal = self
1212            .state
1213            .lock()
1214            .map_err(|_| DecisionExecutorError::new("authorization capture state is unavailable"))?
1215            .terminal;
1216        append_phase_evidence(
1217            &mut evidence,
1218            &self.policy,
1219            request,
1220            response_bytes,
1221            last_defense,
1222            terminal,
1223        )?;
1224        #[cfg(feature = "secret-exposure-review")]
1225        if self.privacy.is_value_free() {
1226            let mut state = self.state.lock().map_err(|_| {
1227                DecisionExecutorError::new("authorization capture state is unavailable")
1228            })?;
1229            if evidence.len() > MAX_VALUE_FREE_AUTHORIZATION_PHASE_RECORDS
1230                || state.staged_value_free_batches.len() >= 2
1231                || state
1232                    .staged_value_free_batches
1233                    .insert(request.stage(), evidence.clone())
1234                    .is_some()
1235            {
1236                return Err(DecisionExecutorError::new(
1237                    "authorization privacy phase was duplicated",
1238                ));
1239            }
1240        }
1241        Ok(evidence)
1242    }
1243}
1244
1245struct CapturedResponse {
1246    leg: CapturedAuthorizationLeg,
1247    evidence: Vec<Evidence>,
1248    defense: AuthorizationPhaseDefense,
1249}
1250
1251#[derive(Clone, Copy)]
1252struct AuthorizationPhaseDefense {
1253    status: u16,
1254    classification: AuthorizationResponseDefense,
1255}
1256
1257fn capture_response(
1258    policy: &AuthorizationReviewPolicy,
1259    subject: &EntityId,
1260    correlation_id: &str,
1261    role: AuthorizationViewRole,
1262    response: crate::http_evidence::CollectedHttpResponse,
1263    privacy: AuthorizationResponsePrivacy,
1264) -> Result<CapturedResponse, DecisionExecutorError> {
1265    if response.final_url() != policy.execution_resource() {
1266        return Err(DecisionExecutorError::new(
1267            "authorization review transport changed the exact resource",
1268        ));
1269    }
1270    let media_type = response.normalized_media_type();
1271    let media_class = if response.has_json_compatible_media_type() {
1272        AuthorizationReviewMediaClass::JsonCompatible
1273    } else if matches!(
1274        media_type.as_deref(),
1275        Some("text/html" | "application/xhtml+xml")
1276    ) {
1277        AuthorizationReviewMediaClass::Html
1278    } else if media_type.is_some() {
1279        AuthorizationReviewMediaClass::Other
1280    } else {
1281        AuthorizationReviewMediaClass::Missing
1282    };
1283    let status = response.status();
1284    let response_digest: [u8; 32] = Sha256::digest(response.body()).into();
1285    let receipt_digest = view_receipt_digest(policy, role, status, media_class, response_digest);
1286    let receipt_id = AuthorizationViewReceiptId::from_digest(receipt_digest);
1287    let defense = response.authorization_response_defense();
1288    let view = if response.body_truncated() {
1289        AuthorizationReviewView::terminal(
1290            policy,
1291            role,
1292            Some(status),
1293            media_class,
1294            AuthorizationReviewBodyState::Truncated,
1295            receipt_id,
1296        )
1297    } else if !response.body_complete() {
1298        AuthorizationReviewView::terminal(
1299            policy,
1300            role,
1301            Some(status),
1302            media_class,
1303            AuthorizationReviewBodyState::Incomplete,
1304            receipt_id,
1305        )
1306    } else if defense == AuthorizationResponseDefense::RateLimited {
1307        AuthorizationReviewView::terminal(
1308            policy,
1309            role,
1310            Some(status),
1311            media_class,
1312            AuthorizationReviewBodyState::RateLimited,
1313            receipt_id,
1314        )
1315    } else if defense == AuthorizationResponseDefense::Challenge {
1316        AuthorizationReviewView::terminal(
1317            policy,
1318            role,
1319            Some(status),
1320            media_class,
1321            AuthorizationReviewBodyState::DefensiveInterference,
1322            receipt_id,
1323        )
1324    } else if (300..400).contains(&status) {
1325        AuthorizationReviewView::terminal(
1326            policy,
1327            role,
1328            Some(status),
1329            media_class,
1330            AuthorizationReviewBodyState::Redirect,
1331            receipt_id,
1332        )
1333    } else if (500..600).contains(&status) {
1334        AuthorizationReviewView::terminal(
1335            policy,
1336            role,
1337            Some(status),
1338            media_class,
1339            AuthorizationReviewBodyState::ServerError,
1340            receipt_id,
1341        )
1342    } else if media_class == AuthorizationReviewMediaClass::Html {
1343        AuthorizationReviewView::terminal(
1344            policy,
1345            role,
1346            Some(status),
1347            media_class,
1348            AuthorizationReviewBodyState::Html,
1349            receipt_id,
1350        )
1351    } else if media_class != AuthorizationReviewMediaClass::JsonCompatible {
1352        AuthorizationReviewView::terminal(
1353            policy,
1354            role,
1355            Some(status),
1356            media_class,
1357            AuthorizationReviewBodyState::UnsupportedMedia,
1358            receipt_id,
1359        )
1360    } else {
1361        match serde_json::from_slice::<serde_json::Value>(response.body()) {
1362            Ok(json) => {
1363                AuthorizationReviewView::capture_json(policy, role, status, &json, receipt_id)
1364            },
1365            Err(_) => AuthorizationReviewView::terminal(
1366                policy,
1367                role,
1368                Some(status),
1369                media_class,
1370                AuthorizationReviewBodyState::MalformedJson,
1371                receipt_id,
1372            ),
1373        }
1374    }
1375    .map_err(|_| DecisionExecutorError::new("authorization response reduction failed"))?;
1376
1377    let request_digest = request_template_digest(policy, role);
1378    let mut values = vec![
1379        format!("role={}", role_name(role)),
1380        format!("policy={}", policy.policy_id()),
1381        format!("resource={}", policy.resource_scope_id()),
1382        format!("request={}", encode_digest(request_digest)),
1383    ];
1384    if !privacy.is_value_free() {
1385        // Preserve the exact historical ordering when privacy is unselected.
1386        values.push(format!("response={}", encode_digest(response_digest)));
1387        values.push(format!("receipt={}", encode_digest(receipt_digest)));
1388    }
1389    values.extend([
1390        format!("status={status}"),
1391        format!("media={}", media_name(media_class)),
1392        format!("state={}", state_name(view.state())),
1393    ]);
1394    let evidence = Evidence::new(
1395        subject.clone(),
1396        EvidenceKind::Custom("authorization-review-leg".to_owned()),
1397        KnowledgePredicate::new(RESOURCE_AUTHORIZATION_EVIDENCE_NAMESPACE, "leg").map_err(
1398            |_| DecisionExecutorError::new("authorization evidence predicate is invalid"),
1399        )?,
1400        EvidenceValue::TextList(values),
1401        EvidenceSource::new(RESOURCE_AUTHORIZATION_EXECUTOR_ID, "bounded-json-view")
1402            .and_then(|source| source.with_correlation_id(correlation_id))
1403            .map_err(|_| DecisionExecutorError::new("authorization evidence source is invalid"))?,
1404        ConfidenceScore::MAX,
1405    );
1406    #[cfg(feature = "secret-exposure-review")]
1407    let (evidence, value_free_transport) = if privacy.is_value_free() {
1408        let metadata = ValueFreeAuthorizationTransport {
1409            // Filled from the new transport root below, not from body bytes.
1410            evidence_id: evidence.id().clone(),
1411            response_bytes: u64::try_from(response.body().len()).unwrap_or(u64::MAX),
1412            complete: response.body_complete(),
1413            truncated: response.body_truncated(),
1414            defense,
1415        };
1416        let root = Evidence::new(
1417            subject.clone(),
1418            EvidenceKind::Custom("authorization-review-leg-transport".to_owned()),
1419            KnowledgePredicate::new(RESOURCE_AUTHORIZATION_EVIDENCE_NAMESPACE, "leg-transport")
1420                .map_err(|_| DecisionExecutorError::new("authorization predicate is invalid"))?,
1421            EvidenceValue::TextList(value_free_transport_values(role, &view, &metadata)),
1422            EvidenceSource::new(RESOURCE_AUTHORIZATION_EXECUTOR_ID, "bounded-json-transport")
1423                .and_then(|source| source.with_correlation_id(correlation_id))
1424                .map_err(|_| {
1425                    DecisionExecutorError::new("authorization evidence source is invalid")
1426                })?,
1427            ConfidenceScore::MAX,
1428        );
1429        let metadata = ValueFreeAuthorizationTransport {
1430            evidence_id: root.id().clone(),
1431            ..metadata
1432        };
1433        let derivation = termivar_core::EvidenceDerivation::new(
1434            vec![root.id().clone()],
1435            termivar_core::DerivationAlgorithm::new("web.authorization-review.value-free-leg", 1)
1436                .map_err(|_| DecisionExecutorError::new("authorization derivation is invalid"))?,
1437        )
1438        .map_err(|_| DecisionExecutorError::new("authorization lineage is invalid"))?;
1439        (
1440            vec![root, evidence.derived_from(derivation)],
1441            Some(metadata),
1442        )
1443    } else {
1444        (vec![evidence], None)
1445    };
1446    #[cfg(not(feature = "secret-exposure-review"))]
1447    let evidence = vec![evidence];
1448    let evidence_id = evidence
1449        .last()
1450        .expect("an authorization leg always produces its classification")
1451        .id()
1452        .clone();
1453    Ok(CapturedResponse {
1454        leg: CapturedAuthorizationLeg {
1455            view,
1456            evidence_id,
1457            #[cfg(feature = "secret-exposure-review")]
1458            value_free_transport,
1459        },
1460        evidence,
1461        defense: AuthorizationPhaseDefense {
1462            status,
1463            classification: defense,
1464        },
1465    })
1466}
1467
1468#[cfg(feature = "secret-exposure-review")]
1469fn value_free_transport_values(
1470    role: AuthorizationViewRole,
1471    view: &AuthorizationReviewView,
1472    metadata: &ValueFreeAuthorizationTransport,
1473) -> Vec<String> {
1474    vec![
1475        format!("role={}", role_name(role)),
1476        "method=GET".to_owned(),
1477        format!("status={}", view.status().unwrap_or(0)),
1478        format!("media={}", media_name(view.media_class())),
1479        format!("complete={}", metadata.complete),
1480        format!("truncated={}", metadata.truncated),
1481        format!("bytes={}", metadata.response_bytes),
1482        format!(
1483            "defense={}",
1484            match metadata.defense {
1485                AuthorizationResponseDefense::Clear => "clear",
1486                AuthorizationResponseDefense::Challenge => "challenge",
1487                AuthorizationResponseDefense::RateLimited => "rate-limited",
1488            }
1489        ),
1490    ]
1491}
1492
1493fn take_role(
1494    captured: &mut BTreeMap<AuthorizationViewRole, CapturedAuthorizationLeg>,
1495    role: AuthorizationViewRole,
1496) -> Result<CapturedAuthorizationLeg, ResourceAuthorizationRuntimeInvariantError> {
1497    captured
1498        .remove(&role)
1499        .ok_or(ResourceAuthorizationRuntimeInvariantError::Catalog)
1500}
1501
1502fn terminal_outcome_for_state(
1503    state: AuthorizationReviewBodyState,
1504) -> Option<AuthorizationReviewOutcome> {
1505    Some(match state {
1506        AuthorizationReviewBodyState::CompleteJson => return None,
1507        AuthorizationReviewBodyState::RateLimited => AuthorizationReviewOutcome::RateLimited,
1508        AuthorizationReviewBodyState::DefensiveInterference => {
1509            AuthorizationReviewOutcome::DefensiveInterference
1510        },
1511        AuthorizationReviewBodyState::Redirect => AuthorizationReviewOutcome::RedirectObserved,
1512        AuthorizationReviewBodyState::UnsupportedMedia | AuthorizationReviewBodyState::Html => {
1513            AuthorizationReviewOutcome::UnsupportedMedia
1514        },
1515        AuthorizationReviewBodyState::MalformedJson => AuthorizationReviewOutcome::MalformedJson,
1516        AuthorizationReviewBodyState::Truncated => AuthorizationReviewOutcome::Truncated,
1517        AuthorizationReviewBodyState::BudgetExhausted => {
1518            AuthorizationReviewOutcome::BudgetExhausted
1519        },
1520        AuthorizationReviewBodyState::Cancelled => AuthorizationReviewOutcome::Cancelled,
1521        AuthorizationReviewBodyState::ServerError | AuthorizationReviewBodyState::Incomplete => {
1522            AuthorizationReviewOutcome::Incomplete
1523        },
1524    })
1525}
1526
1527fn append_phase_evidence(
1528    evidence: &mut Vec<Evidence>,
1529    policy: &AuthorizationReviewPolicy,
1530    request: &DecisionExecutionRequest,
1531    response_bytes: u64,
1532    defense: Option<AuthorizationPhaseDefense>,
1533    terminal: Option<AuthorizationReviewOutcome>,
1534) -> Result<(), DecisionExecutorError> {
1535    let source = |method: &'static str| {
1536        EvidenceSource::new(RESOURCE_AUTHORIZATION_EXECUTOR_ID, method)
1537            .and_then(|source| source.with_correlation_id(request.case().id()))
1538            .map_err(|_| DecisionExecutorError::new("authorization evidence source is invalid"))
1539    };
1540    let direct = |kind: EvidenceKind,
1541                  predicate: KnowledgePredicate,
1542                  value: EvidenceValue,
1543                  method: &'static str|
1544     -> Result<Evidence, DecisionExecutorError> {
1545        Ok(Evidence::new(
1546            request.case().subject().clone(),
1547            kind,
1548            predicate,
1549            value,
1550            source(method)?,
1551            ConfidenceScore::MAX,
1552        ))
1553    };
1554    evidence.push(direct(
1555        EvidenceKind::Content,
1556        termivar_core::HttpEvidencePredicate::RESPONSE_BODY_BYTES_OBSERVED.into_knowledge(),
1557        EvidenceValue::Unsigned(response_bytes),
1558        "response-body-size",
1559    )?);
1560    let no_response_terminal = defense.is_none() && terminal.is_some();
1561    evidence.push(direct(
1562        EvidenceKind::Custom("authorization-review-phase".to_owned()),
1563        KnowledgePredicate::new(
1564            RESOURCE_AUTHORIZATION_EVIDENCE_NAMESPACE,
1565            AUTHORIZATION_NO_RESPONSE_TERMINAL_PREDICATE,
1566        )
1567        .map_err(|_| DecisionExecutorError::new("authorization predicate is invalid"))?,
1568        EvidenceValue::Boolean(no_response_terminal),
1569        "phase-terminal",
1570    )?);
1571    evidence.push(direct(
1572        EvidenceKind::Custom("authorization-review-phase".to_owned()),
1573        crate::web_actions::authorization_review_phase_terminal_predicate(),
1574        EvidenceValue::Boolean(terminal.is_some()),
1575        "phase-terminal",
1576    )?);
1577
1578    if request.stage() == DecisionExecutionStage::Active && terminal.is_none() {
1579        evidence.push(direct(
1580            EvidenceKind::Custom("native-web-review-response".to_owned()),
1581            crate::web_actions::native_web_review_response_marker_predicate(),
1582            EvidenceValue::Boolean(true),
1583            "pair-complete",
1584        )?);
1585    }
1586
1587    let Some(defense) = defense else {
1588        return Ok(());
1589    };
1590    let (challenged, rate_limited) = match defense.classification {
1591        AuthorizationResponseDefense::Clear => (false, false),
1592        AuthorizationResponseDefense::Challenge => (true, false),
1593        AuthorizationResponseDefense::RateLimited => (false, true),
1594    };
1595    let status = defense.status;
1596    let base = [
1597        (
1598            AUTHORIZATION_DEFENSE_STATUS_PREDICATE,
1599            EvidenceValue::Unsigned(u64::from(status)),
1600        ),
1601        (
1602            AUTHORIZATION_DEFENSE_CHALLENGE_PREDICATE,
1603            EvidenceValue::Boolean(challenged),
1604        ),
1605        (
1606            AUTHORIZATION_DEFENSE_RATE_LIMIT_PREDICATE,
1607            EvidenceValue::Boolean(rate_limited),
1608        ),
1609        (
1610            AUTHORIZATION_DEFENSE_SCOPE_PREDICATE,
1611            EvidenceValue::Text(policy.resource_scope_id().to_wire()),
1612        ),
1613    ];
1614    let mut parents = Vec::with_capacity(base.len() + 1);
1615    parents.push(
1616        evidence
1617            .iter()
1618            .find(|item| {
1619                item.predicate()
1620                    == &termivar_core::HttpEvidencePredicate::RESPONSE_BODY_BYTES_OBSERVED
1621                        .into_knowledge()
1622            })
1623            .ok_or_else(|| DecisionExecutorError::new("authorization byte evidence is missing"))?
1624            .id()
1625            .clone(),
1626    );
1627    for (name, value) in base {
1628        let item = direct(
1629            EvidenceKind::Custom("authorization-review-defense-base".to_owned()),
1630            KnowledgePredicate::new(RESOURCE_AUTHORIZATION_EVIDENCE_NAMESPACE, name)
1631                .map_err(|_| DecisionExecutorError::new("authorization predicate is invalid"))?,
1632            value,
1633            "defense-base",
1634        )?;
1635        parents.push(item.id().clone());
1636        evidence.push(item);
1637    }
1638    let signal = AssessmentDefenseSignal::new(
1639        DefenseState::from_authorization_projection(status, challenged, rate_limited),
1640        AssessmentDefenseBodyCoverage::MetadataOnly,
1641        false,
1642    );
1643    evidence.extend(
1644        project_assessment_defense_signal(
1645            &signal,
1646            AssessmentDefenseProjectionContext {
1647                subject: request.case().subject(),
1648                case_id: request.case().id(),
1649                executor_id: RESOURCE_AUTHORIZATION_EXECUTOR_ID,
1650                reliability: ConfidenceScore::MAX,
1651                parents,
1652            },
1653        )
1654        .map_err(|_| DecisionExecutorError::new("authorization defense projection failed"))?,
1655    );
1656    Ok(())
1657}
1658
1659fn view_receipt_digest(
1660    policy: &AuthorizationReviewPolicy,
1661    role: AuthorizationViewRole,
1662    status: u16,
1663    media: AuthorizationReviewMediaClass,
1664    response_digest: [u8; 32],
1665) -> [u8; 32] {
1666    let mut digest = Sha256::new();
1667    update_framed(&mut digest, RESOURCE_AUTHORIZATION_RECEIPT_DOMAIN);
1668    update_framed(&mut digest, &policy.policy_id().as_bytes());
1669    update_framed(&mut digest, &policy.resource_scope_id().as_bytes());
1670    update_framed(&mut digest, role_name(role).as_bytes());
1671    update_framed(&mut digest, &status.to_be_bytes());
1672    update_framed(&mut digest, media_name(media).as_bytes());
1673    update_framed(&mut digest, &response_digest);
1674    digest.finalize().into()
1675}
1676
1677fn request_template_digest(
1678    policy: &AuthorizationReviewPolicy,
1679    role: AuthorizationViewRole,
1680) -> [u8; 32] {
1681    let mut digest = Sha256::new();
1682    update_framed(&mut digest, RESOURCE_AUTHORIZATION_REQUEST_DOMAIN);
1683    update_framed(&mut digest, &policy.policy_id().as_bytes());
1684    update_framed(&mut digest, &policy.resource_scope_id().as_bytes());
1685    update_framed(&mut digest, b"GET");
1686    update_framed(&mut digest, b"application/json");
1687    let principal_role: &[u8] = match role {
1688        AuthorizationViewRole::PrimaryCandidate | AuthorizationViewRole::PrimaryReplay => {
1689            b"primary".as_slice()
1690        },
1691        AuthorizationViewRole::PeerCandidate | AuthorizationViewRole::PeerReplay => {
1692            b"peer".as_slice()
1693        },
1694    };
1695    update_framed(&mut digest, principal_role);
1696    digest.finalize().into()
1697}
1698
1699fn update_framed(digest: &mut Sha256, value: &[u8]) {
1700    digest.update(u64::try_from(value.len()).unwrap_or(u64::MAX).to_be_bytes());
1701    digest.update(value);
1702}
1703
1704fn encode_digest(value: [u8; 32]) -> String {
1705    const HEX: &[u8; 16] = b"0123456789abcdef";
1706    let mut output = String::with_capacity(64);
1707    for byte in value {
1708        output.push(char::from(HEX[usize::from(byte >> 4)]));
1709        output.push(char::from(HEX[usize::from(byte & 0x0f)]));
1710    }
1711    output
1712}
1713
1714const fn role_name(role: AuthorizationViewRole) -> &'static str {
1715    match role {
1716        AuthorizationViewRole::PrimaryCandidate => "primary_candidate",
1717        AuthorizationViewRole::PeerCandidate => "peer_candidate",
1718        AuthorizationViewRole::PrimaryReplay => "primary_replay",
1719        AuthorizationViewRole::PeerReplay => "peer_replay",
1720    }
1721}
1722
1723const fn media_name(media: AuthorizationReviewMediaClass) -> &'static str {
1724    match media {
1725        AuthorizationReviewMediaClass::JsonCompatible => "json_compatible",
1726        AuthorizationReviewMediaClass::Html => "html",
1727        AuthorizationReviewMediaClass::Other => "other",
1728        AuthorizationReviewMediaClass::Missing => "missing",
1729    }
1730}
1731
1732const fn state_name(state: AuthorizationReviewBodyState) -> &'static str {
1733    match state {
1734        AuthorizationReviewBodyState::CompleteJson => "complete_json",
1735        AuthorizationReviewBodyState::UnsupportedMedia => "unsupported_media",
1736        AuthorizationReviewBodyState::Html => "html",
1737        AuthorizationReviewBodyState::Redirect => "redirect",
1738        AuthorizationReviewBodyState::RateLimited => "rate_limited",
1739        AuthorizationReviewBodyState::ServerError => "server_error",
1740        AuthorizationReviewBodyState::MalformedJson => "malformed_json",
1741        AuthorizationReviewBodyState::Truncated => "truncated",
1742        AuthorizationReviewBodyState::Incomplete => "incomplete",
1743        AuthorizationReviewBodyState::BudgetExhausted => "budget_exhausted",
1744        AuthorizationReviewBodyState::Cancelled => "cancelled",
1745        AuthorizationReviewBodyState::DefensiveInterference => "defensive_interference",
1746    }
1747}
1748
1749/// Projects at most one review-only item from exact positive four-view truth.
1750pub(super) fn project_resource_authorization_item(
1751    context: &mut AssessmentProjectionContext,
1752    knowledge: &KnowledgeBase,
1753    review: &CommittedResourceAuthorizationReview,
1754) -> Result<(), AssessmentItemProjectionError> {
1755    let all = review
1756        .primary_evidence_ids
1757        .iter()
1758        .chain(review.peer_evidence_ids.iter())
1759        .cloned()
1760        .collect::<BTreeSet<_>>();
1761    if all.len() != MAX_AUTHORIZATION_REVIEW_REQUESTS {
1762        return Err(AssessmentItemProjectionError::DuplicateEvidenceReference);
1763    }
1764    for evidence_id in all {
1765        context.register_evidence(knowledge, &evidence_id)?;
1766    }
1767    if review.outcome() == AuthorizationReviewOutcome::StableCrossPrincipalEquivalence {
1768        let mut target_digest = Sha256::new();
1769        update_framed(
1770            &mut target_digest,
1771            b"security.authorization-review.item-target.v1\0",
1772        );
1773        update_framed(&mut target_digest, &review.policy_id.as_bytes());
1774        update_framed(&mut target_digest, &review.resource_scope_id.as_bytes());
1775        update_framed(
1776            &mut target_digest,
1777            crate::authorization_review::AUTHORIZATION_REVIEW_ALGORITHM_VERSION.as_bytes(),
1778        );
1779        let target = AssessmentItemTarget::authorization_resource(format!(
1780            "authorization-resource@1:{}",
1781            encode_digest(target_digest.finalize().into())
1782        ))?;
1783        context.project_differential(
1784            &RESOURCE_AUTHORIZATION_CAPABILITY,
1785            knowledge,
1786            &review.subject,
1787            &target,
1788            &review.primary_evidence_ids,
1789            &review.peer_evidence_ids,
1790        )?;
1791    }
1792    Ok(())
1793}
1794
1795#[cfg(test)]
1796mod tests {
1797    use super::*;
1798
1799    #[test]
1800    fn closed_catalog_and_audit_debug_are_bounded() {
1801        assert_eq!(MAX_AUTHORIZATION_REVIEW_RESOURCES, 1);
1802        assert_eq!(MAX_AUTHORIZATION_REVIEW_REQUESTS, 4);
1803        assert_eq!(MAX_AUTHORIZATION_REVIEW_ACTIVE_VERIFICATIONS, 1);
1804        assert_eq!(
1805            ResourceAuthorizationDecisionExecutor::roles_for_stage(DecisionExecutionStage::Passive),
1806            [
1807                AuthorizationViewRole::PrimaryCandidate,
1808                AuthorizationViewRole::PeerCandidate
1809            ]
1810        );
1811        assert_eq!(
1812            ResourceAuthorizationDecisionExecutor::roles_for_stage(DecisionExecutionStage::Active),
1813            [
1814                AuthorizationViewRole::PrimaryReplay,
1815                AuthorizationViewRole::PeerReplay
1816            ]
1817        );
1818    }
1819
1820    #[test]
1821    fn request_identity_is_role_stable_and_credential_free() {
1822        let origin = url::Url::parse("https://example.test/").unwrap();
1823        let source = br#"
1824schema = "security.authorization-review-policy/v1"
1825resource = "/api/accounts/42"
1826resource_handle = "account-self-profile"
1827expectation = "primary-only"
1828method = "GET"
1829[comparison]
1830selected_paths = ["/data/account"]
1831ignored_paths = []
1832unordered_array_paths = []
1833max_diff_paths = 8
1834"#;
1835        let policy = AuthorizationReviewPolicy::parse_toml(&origin, source).unwrap();
1836        assert_eq!(
1837            request_template_digest(&policy, AuthorizationViewRole::PrimaryCandidate),
1838            request_template_digest(&policy, AuthorizationViewRole::PrimaryReplay)
1839        );
1840        assert_ne!(
1841            request_template_digest(&policy, AuthorizationViewRole::PrimaryCandidate),
1842            request_template_digest(&policy, AuthorizationViewRole::PeerCandidate)
1843        );
1844    }
1845
1846    #[cfg(feature = "secret-exposure-review")]
1847    mod privacy_tests {
1848        use super::*;
1849        use crate::{
1850            runtime_budget::RequestAccountingBroker, DecisionEvidenceReceipt, DecisionLoopCommand,
1851            DecisionRunnerAdapter, DecisionStopReason, KnowledgeWrite, RuntimeBudget, RuntimeUsage,
1852            StandardWebDecisionRunReport, VerificationCase,
1853        };
1854        use termivar_core::{DerivationAlgorithm, EvidenceDerivation};
1855
1856        const CASE: &str = "case:synthetic-authorization-value-free";
1857        const BODY: &[u8] = br#"{"data":{"account":{"id":"alpha"}}}"#;
1858
1859        fn subject() -> EntityId {
1860            EntityId::new("endpoint:https://example.test/").unwrap()
1861        }
1862
1863        fn policy() -> AuthorizationReviewPolicy {
1864            AuthorizationReviewPolicy::parse_toml(
1865                &url::Url::parse("https://example.test/").unwrap(),
1866                br#"schema = "security.authorization-review-policy/v1"
1867resource = "/api/accounts/42"
1868resource_handle = "synthetic-selected-account"
1869expectation = "primary-only"
1870method = "GET"
1871[comparison]
1872selected_paths = ["/data/account"]
1873ignored_paths = []
1874unordered_array_paths = []
1875max_diff_paths = 8
1876"#,
1877            )
1878            .unwrap()
1879        }
1880
1881        fn fact(name: &str, kind: EvidenceKind, value: EvidenceValue, method: &str) -> Evidence {
1882            Evidence::new(
1883                subject(),
1884                kind,
1885                KnowledgePredicate::new(RESOURCE_AUTHORIZATION_EVIDENCE_NAMESPACE, name).unwrap(),
1886                value,
1887                EvidenceSource::new(RESOURCE_AUTHORIZATION_EXECUTOR_ID, method)
1888                    .unwrap()
1889                    .with_correlation_id(CASE)
1890                    .unwrap(),
1891                ConfidenceScore::MAX,
1892            )
1893        }
1894
1895        // Independent framing, not the production request-identity helper.
1896        // This is operator-policy identity, not a response/version oracle.
1897        fn independent_request(policy: &AuthorizationReviewPolicy) -> String {
1898            let mut sha = Sha256::new();
1899            for part in [
1900                b"security.authorization-review.request-template.v1\0".as_slice(),
1901                &policy.policy_id().as_bytes(),
1902                &policy.resource_scope_id().as_bytes(),
1903                b"GET",
1904                b"application/json",
1905                b"primary",
1906            ] {
1907                sha.update((part.len() as u64).to_be_bytes());
1908                sha.update(part);
1909            }
1910            format!("{:x}", sha.finalize())
1911        }
1912
1913        fn literal_batch(policy: &AuthorizationReviewPolicy) -> Vec<Evidence> {
1914            let mut batch = Vec::new();
1915            for role in ["primary_candidate", "peer_candidate"] {
1916                let request = if role == "primary_candidate" {
1917                    independent_request(policy)
1918                } else {
1919                    let mut sha = Sha256::new();
1920                    for part in [
1921                        b"security.authorization-review.request-template.v1\0".as_slice(),
1922                        &policy.policy_id().as_bytes(),
1923                        &policy.resource_scope_id().as_bytes(),
1924                        b"GET",
1925                        b"application/json",
1926                        b"peer",
1927                    ] {
1928                        sha.update((part.len() as u64).to_be_bytes());
1929                        sha.update(part);
1930                    }
1931                    format!("{:x}", sha.finalize())
1932                };
1933                let root = fact(
1934                    "leg-transport",
1935                    EvidenceKind::Custom("authorization-review-leg-transport".to_owned()),
1936                    EvidenceValue::TextList(vec![
1937                        format!("role={role}"),
1938                        "method=GET".to_owned(),
1939                        "status=200".to_owned(),
1940                        "media=json_compatible".to_owned(),
1941                        "complete=true".to_owned(),
1942                        "truncated=false".to_owned(),
1943                        format!("bytes={}", BODY.len()),
1944                        "defense=clear".to_owned(),
1945                    ]),
1946                    "bounded-json-transport",
1947                );
1948                let leg = fact(
1949                    "leg",
1950                    EvidenceKind::Custom("authorization-review-leg".to_owned()),
1951                    EvidenceValue::TextList(vec![
1952                        format!("role={role}"),
1953                        format!("policy={}", policy.policy_id()),
1954                        format!("resource={}", policy.resource_scope_id()),
1955                        format!("request={request}"),
1956                        "status=200".to_owned(),
1957                        "media=json_compatible".to_owned(),
1958                        "state=complete_json".to_owned(),
1959                    ]),
1960                    "bounded-json-view",
1961                )
1962                .derived_from(
1963                    EvidenceDerivation::new(
1964                        vec![root.id().clone()],
1965                        DerivationAlgorithm::new("web.authorization-review.value-free-leg", 1)
1966                            .unwrap(),
1967                    )
1968                    .unwrap(),
1969                );
1970                batch.extend([root, leg]);
1971            }
1972            let bytes = Evidence::new(
1973                subject(),
1974                EvidenceKind::Content,
1975                termivar_core::HttpEvidencePredicate::RESPONSE_BODY_BYTES_OBSERVED.into_knowledge(),
1976                EvidenceValue::Unsigned((BODY.len() * 2) as u64),
1977                EvidenceSource::new(RESOURCE_AUTHORIZATION_EXECUTOR_ID, "response-body-size")
1978                    .unwrap()
1979                    .with_correlation_id(CASE)
1980                    .unwrap(),
1981                ConfidenceScore::MAX,
1982            );
1983            let mut defense_parents = vec![bytes.id().clone()];
1984            batch.push(bytes);
1985            for name in ["no-response-terminal", "phase-terminal"] {
1986                batch.push(fact(
1987                    name,
1988                    EvidenceKind::Custom("authorization-review-phase".to_owned()),
1989                    EvidenceValue::Boolean(false),
1990                    "phase-terminal",
1991                ));
1992            }
1993            for (name, value) in [
1994                ("defense-status", EvidenceValue::Unsigned(200)),
1995                ("defense-challenge", EvidenceValue::Boolean(false)),
1996                ("defense-rate-limit", EvidenceValue::Boolean(false)),
1997                (
1998                    "defense-resource-scope",
1999                    EvidenceValue::Text(policy.resource_scope_id().to_wire()),
2000                ),
2001            ] {
2002                let item = fact(
2003                    name,
2004                    EvidenceKind::Custom("authorization-review-defense-base".to_owned()),
2005                    value,
2006                    "defense-base",
2007                );
2008                defense_parents.push(item.id().clone());
2009                batch.push(item);
2010            }
2011            // Existing defense projector supplies its own independently tested
2012            // closed metadata vocabulary, not an authorization comparison oracle.
2013            batch.extend(
2014                project_assessment_defense_signal(
2015                    &AssessmentDefenseSignal::new(
2016                        DefenseState::from_authorization_projection(200, false, false),
2017                        AssessmentDefenseBodyCoverage::MetadataOnly,
2018                        false,
2019                    ),
2020                    AssessmentDefenseProjectionContext {
2021                        subject: &subject(),
2022                        case_id: CASE,
2023                        executor_id: RESOURCE_AUTHORIZATION_EXECUTOR_ID,
2024                        reliability: ConfidenceScore::MAX,
2025                        parents: defense_parents,
2026                    },
2027                )
2028                .unwrap(),
2029            );
2030            batch
2031        }
2032
2033        fn state(
2034            policy: &AuthorizationReviewPolicy,
2035            original: &[Evidence],
2036            staged: Vec<Evidence>,
2037        ) -> ResourceAuthorizationExecutionState {
2038            let mut captured = BTreeMap::new();
2039            for (index, role) in [
2040                AuthorizationViewRole::PrimaryCandidate,
2041                AuthorizationViewRole::PeerCandidate,
2042            ]
2043            .into_iter()
2044            .enumerate()
2045            {
2046                // Only private staged state is constructed here. No expected
2047                // disposition or claimed native output comes from this capture.
2048                let view = AuthorizationReviewView::capture_json(
2049                    policy,
2050                    role,
2051                    200,
2052                    &serde_json::from_slice(BODY).unwrap(),
2053                    AuthorizationViewReceiptId::from_digest([index as u8 + 1; 32]),
2054                )
2055                .unwrap();
2056                captured.insert(
2057                    role,
2058                    CapturedAuthorizationLeg {
2059                        view,
2060                        evidence_id: original[index * 2 + 1].id().clone(),
2061                        value_free_transport: Some(ValueFreeAuthorizationTransport {
2062                            evidence_id: original[index * 2].id().clone(),
2063                            response_bytes: BODY.len() as u64,
2064                            complete: true,
2065                            truncated: false,
2066                            defense: AuthorizationResponseDefense::Clear,
2067                        }),
2068                    },
2069                );
2070            }
2071            ResourceAuthorizationExecutionState {
2072                captured,
2073                staged_value_free_batches: BTreeMap::from([(
2074                    DecisionExecutionStage::Passive,
2075                    staged,
2076                )]),
2077                ..ResourceAuthorizationExecutionState::default()
2078            }
2079        }
2080
2081        struct LiteralExecutor {
2082            id: &'static str,
2083            batch: Vec<Evidence>,
2084        }
2085        #[async_trait]
2086        impl DecisionActionExecutor for LiteralExecutor {
2087            fn id(&self) -> &str {
2088                self.id
2089            }
2090            async fn execute(
2091                &self,
2092                _: &DecisionExecutionRequest,
2093            ) -> Result<Vec<Evidence>, DecisionExecutorError> {
2094                assert!(self.batch.len() <= 32);
2095                Ok(self.batch.clone())
2096            }
2097        }
2098
2099        async fn commit(
2100            batch: Vec<Evidence>,
2101            action: &str,
2102            executor: &'static str,
2103            stage: DecisionExecutionStage,
2104        ) -> (KnowledgeBase, DecisionEvidenceReceipt) {
2105            try_commit(batch, action, executor, stage).await.unwrap()
2106        }
2107
2108        async fn try_commit(
2109            batch: Vec<Evidence>,
2110            action: &str,
2111            executor: &'static str,
2112            stage: DecisionExecutionStage,
2113        ) -> Result<(KnowledgeBase, DecisionEvidenceReceipt), crate::DecisionRunnerError> {
2114            let batch = if executor == RESOURCE_AUTHORIZATION_EXECUTOR_ID {
2115                batch
2116            } else {
2117                // The alternate-route fixture itself must satisfy the runner's
2118                // source contract before the narrower authorization gate runs.
2119                batch
2120                    .into_iter()
2121                    .map(|item| {
2122                        let source = EvidenceSource::new(executor, item.source().method())
2123                            .unwrap()
2124                            .with_correlation_id(CASE)
2125                            .unwrap();
2126                        replaced(
2127                            &item,
2128                            item.value().clone(),
2129                            item.kind().clone(),
2130                            source,
2131                            None,
2132                        )
2133                    })
2134                    .collect()
2135            };
2136            let knowledge = KnowledgeBase::new();
2137            let mut registry = DecisionExecutorRegistry::new();
2138            registry
2139                .register(Arc::new(LiteralExecutor {
2140                    id: executor,
2141                    batch,
2142                }))
2143                .unwrap();
2144            registry.route_action(stage, action, executor).unwrap();
2145            let case = VerificationCase::new(
2146                CASE,
2147                subject(),
2148                action,
2149                "hypothesis:synthetic-authorization",
2150            )
2151            .unwrap()
2152            .without_hypothesis_transition();
2153            let command = match stage {
2154                DecisionExecutionStage::Passive => DecisionLoopCommand::ExecuteAction {
2155                    case,
2156                    executor: None,
2157                    origin: DecisionActionOrigin::Planned,
2158                    delay_ms: None,
2159                },
2160                DecisionExecutionStage::Active => {
2161                    DecisionLoopCommand::CollectActiveEvidence { case }
2162                },
2163            };
2164            let receipt = DecisionRunnerAdapter::new(registry)
2165                .execute_command(&command, &knowledge)
2166                .await?;
2167            Ok((knowledge, receipt))
2168        }
2169
2170        fn report(receipt: Option<DecisionEvidenceReceipt>) -> StandardWebDecisionRunReport {
2171            let accounting = RequestAccountingBroker::new(RuntimeBudget::default());
2172            for _ in 0..2 {
2173                let mut lease = accounting
2174                    .try_begin(
2175                        RESOURCE_AUTHORIZATION_REVIEW_ACTION_ID,
2176                        DecisionExecutionStage::Passive,
2177                        Some(DecisionActionOrigin::Planned),
2178                    )
2179                    .unwrap();
2180                assert_eq!(
2181                    lease.observe_response_bytes(BODY.len() as u64),
2182                    BODY.len() as u64
2183                );
2184                lease.finish(TransportDispatchOutcome::Completed);
2185            }
2186            StandardWebDecisionRunReport {
2187                bootstrap: None,
2188                turns: Vec::new(),
2189                unverified_evidence: receipt,
2190                terminal: DecisionLoopCommand::Halt {
2191                    reason: DecisionStopReason::NoEligibleAction,
2192                },
2193                usage: RuntimeUsage::default(),
2194                transport: accounting.dispatch_audit(),
2195                limit_exceeded: None,
2196                execution_failure: None,
2197            }
2198        }
2199
2200        fn replaced(
2201            item: &Evidence,
2202            value: EvidenceValue,
2203            kind: EvidenceKind,
2204            source: EvidenceSource,
2205            parents: Option<Vec<EvidenceId>>,
2206        ) -> Evidence {
2207            let mut next = Evidence::with_id_at(
2208                item.id().clone(),
2209                item.subject().clone(),
2210                kind,
2211                item.predicate().clone(),
2212                value,
2213                source,
2214                item.reliability(),
2215                item.observed_at_ms(),
2216            );
2217            if let Some(derivation) = item.origin().derivation() {
2218                next = next.derived_from(
2219                    EvidenceDerivation::new(
2220                        parents.unwrap_or_else(|| derivation.parents().to_vec()),
2221                        derivation.algorithm().clone(),
2222                    )
2223                    .unwrap(),
2224                );
2225            }
2226            next
2227        }
2228
2229        #[tokio::test]
2230        async fn authorization_privacy_commits_require_literal_role_roots_and_real_parent_writes() {
2231            let policy = policy();
2232            let original = literal_batch(&policy);
2233            let (knowledge, receipt) = commit(
2234                original.clone(),
2235                RESOURCE_AUTHORIZATION_REVIEW_ACTION_ID,
2236                RESOURCE_AUTHORIZATION_EXECUTOR_ID,
2237                DecisionExecutionStage::Passive,
2238            )
2239            .await;
2240            assert!(validate_value_free_authorization_commits(
2241                &report(Some(receipt.clone())),
2242                &knowledge,
2243                &policy,
2244                &subject(),
2245                &state(&policy, &original, original.clone()),
2246                false,
2247            )
2248            .is_ok());
2249            assert!(validate_value_free_authorization_commits(
2250                &report(None),
2251                &knowledge,
2252                &policy,
2253                &subject(),
2254                &state(&policy, &original, original.clone()),
2255                false,
2256            )
2257            .is_err());
2258            for mutation in 0..15 {
2259                let mut batch = original.clone();
2260                match mutation {
2261                    0 => {
2262                        let _ = batch.remove(0);
2263                    },
2264                    1 => {
2265                        let _ = batch.remove(1);
2266                    },
2267                    2 => batch.push(batch[0].clone()),
2268                    3 => batch.swap(0, 2),
2269                    4 => {
2270                        batch[0] = replaced(
2271                            &batch[0],
2272                            EvidenceValue::TextList(vec!["role=peer_candidate".to_owned()]),
2273                            batch[0].kind().clone(),
2274                            batch[0].source().clone(),
2275                            None,
2276                        )
2277                    },
2278                    5 => {
2279                        batch[0] = replaced(
2280                            &batch[0],
2281                            batch[0].value().clone(),
2282                            EvidenceKind::Content,
2283                            batch[0].source().clone(),
2284                            None,
2285                        )
2286                    },
2287                    6 => {
2288                        batch[0] = replaced(
2289                            &batch[0],
2290                            batch[0].value().clone(),
2291                            batch[0].kind().clone(),
2292                            EvidenceSource::new(
2293                                RESOURCE_AUTHORIZATION_EXECUTOR_ID,
2294                                "forged-transport",
2295                            )
2296                            .unwrap()
2297                            .with_correlation_id(CASE)
2298                            .unwrap(),
2299                            None,
2300                        )
2301                    },
2302                    7 => {
2303                        batch[1] = replaced(
2304                            &batch[1],
2305                            batch[1].value().clone(),
2306                            batch[1].kind().clone(),
2307                            batch[1].source().clone(),
2308                            Some(vec![batch[2].id().clone()]),
2309                        )
2310                    },
2311                    8 => {
2312                        batch[1] = replaced(
2313                            &batch[1],
2314                            EvidenceValue::TextList(vec![
2315                                "response=low-entropy-body-sha256".to_owned()
2316                            ]),
2317                            batch[1].kind().clone(),
2318                            batch[1].source().clone(),
2319                            None,
2320                        )
2321                    },
2322                    9 => {
2323                        let index = batch
2324                            .iter()
2325                            .position(|row| row.predicate().name() == "defense-rate-limit")
2326                            .unwrap();
2327                        batch[index] = replaced(
2328                            &batch[index],
2329                            EvidenceValue::Boolean(true),
2330                            batch[index].kind().clone(),
2331                            batch[index].source().clone(),
2332                            None,
2333                        );
2334                    },
2335                    10 => {
2336                        batch[4] = replaced(
2337                            &batch[4],
2338                            EvidenceValue::Unsigned(1),
2339                            batch[4].kind().clone(),
2340                            batch[4].source().clone(),
2341                            None,
2342                        );
2343                    },
2344                    11 => batch.push(fact(
2345                        "unknown-fact",
2346                        EvidenceKind::Content,
2347                        EvidenceValue::Text("hidden-body-identity".to_owned()),
2348                        "unknown",
2349                    )),
2350                    12 => {
2351                        let index = batch
2352                            .iter()
2353                            .position(|row| row.predicate().name() == "phase-terminal")
2354                            .unwrap();
2355                        batch[index] = replaced(
2356                            &batch[index],
2357                            EvidenceValue::Boolean(true),
2358                            batch[index].kind().clone(),
2359                            batch[index].source().clone(),
2360                            None,
2361                        );
2362                    },
2363                    13 => {
2364                        let index = batch
2365                            .iter()
2366                            .position(|row| row.predicate().name() == "no-response-terminal")
2367                            .unwrap();
2368                        batch[index] = replaced(
2369                            &batch[index],
2370                            EvidenceValue::Boolean(true),
2371                            batch[index].kind().clone(),
2372                            batch[index].source().clone(),
2373                            None,
2374                        );
2375                    },
2376                    14 => {
2377                        let _ = batch.pop();
2378                    },
2379                    _ => unreachable!(),
2380                }
2381                // Parent-transaction rejection is distinct from a committed
2382                // negative reaching the narrower selected proof. Never claim a
2383                // missing-parent batch committed if the runner rejected it.
2384                let committed = try_commit(
2385                    batch.clone(),
2386                    RESOURCE_AUTHORIZATION_REVIEW_ACTION_ID,
2387                    RESOURCE_AUTHORIZATION_EXECUTOR_ID,
2388                    DecisionExecutionStage::Passive,
2389                )
2390                .await;
2391                let (knowledge, receipt) = match committed {
2392                    Ok(committed) => committed,
2393                    Err(error) => {
2394                        assert!(
2395                            matches!(mutation, 0 | 3 | 7),
2396                            "unexpected upstream rejection mutation={mutation}"
2397                        );
2398                        assert!(format!("{error:?}").contains("MissingDerivationParent"));
2399                        continue;
2400                    },
2401                };
2402                assert!(
2403                    validate_value_free_authorization_commits(
2404                        &report(Some(receipt)),
2405                        &knowledge,
2406                        &policy,
2407                        &subject(),
2408                        &state(&policy, &original, batch),
2409                        false,
2410                    )
2411                    .is_err(),
2412                    "mutation={mutation}"
2413                );
2414            }
2415        }
2416
2417        #[tokio::test]
2418        async fn authorization_privacy_terminal_state_must_agree_with_last_committed_phase() {
2419            let policy = policy();
2420            let batch = literal_batch(&policy);
2421            let (knowledge, receipt) = commit(
2422                batch.clone(),
2423                RESOURCE_AUTHORIZATION_REVIEW_ACTION_ID,
2424                RESOURCE_AUTHORIZATION_EXECUTOR_ID,
2425                DecisionExecutionStage::Passive,
2426            )
2427            .await;
2428            for outcome in [
2429                AuthorizationReviewOutcome::RateLimited,
2430                AuthorizationReviewOutcome::Incomplete,
2431            ] {
2432                let mut staged = state(&policy, &batch, batch.clone());
2433                // Both private views and the actual committed footer say a
2434                // complete nonterminal candidate phase. A stray terminal may
2435                // not overwrite that evidence with a fabricated stop category.
2436                staged.terminal = Some(outcome);
2437                assert!(validate_value_free_authorization_commits(
2438                    &report(Some(receipt.clone())),
2439                    &knowledge,
2440                    &policy,
2441                    &subject(),
2442                    &staged,
2443                    false,
2444                )
2445                .is_err());
2446            }
2447        }
2448
2449        #[tokio::test]
2450        async fn authorization_privacy_commits_reject_wrong_route_or_uncommitted_storage() {
2451            let policy = policy();
2452            let batch = literal_batch(&policy);
2453            for (action, executor, stage) in [
2454                (
2455                    "web.review.unrelated",
2456                    RESOURCE_AUTHORIZATION_EXECUTOR_ID,
2457                    DecisionExecutionStage::Passive,
2458                ),
2459                (
2460                    RESOURCE_AUTHORIZATION_REVIEW_ACTION_ID,
2461                    "http.unrelated",
2462                    DecisionExecutionStage::Passive,
2463                ),
2464                (
2465                    RESOURCE_AUTHORIZATION_REVIEW_ACTION_ID,
2466                    RESOURCE_AUTHORIZATION_EXECUTOR_ID,
2467                    DecisionExecutionStage::Active,
2468                ),
2469            ] {
2470                let (knowledge, receipt) = commit(batch.clone(), action, executor, stage).await;
2471                assert!(validate_value_free_authorization_commits(
2472                    &report(Some(receipt)),
2473                    &knowledge,
2474                    &policy,
2475                    &subject(),
2476                    &state(&policy, &batch, batch.clone()),
2477                    false,
2478                )
2479                .is_err());
2480            }
2481            let (knowledge, receipt) = commit(
2482                batch.clone(),
2483                RESOURCE_AUTHORIZATION_REVIEW_ACTION_ID,
2484                RESOURCE_AUTHORIZATION_EXECUTOR_ID,
2485                DecisionExecutionStage::Passive,
2486            )
2487            .await;
2488            for writes in [
2489                vec![KnowledgeWrite::Inserted; batch.len() - 1],
2490                vec![KnowledgeWrite::Updated; batch.len()],
2491            ] {
2492                let altered = receipt.with_test_committed_batch(
2493                    batch.clone(),
2494                    writes,
2495                    knowledge.snapshot_for_subject(&subject()),
2496                );
2497                assert!(validate_value_free_authorization_commits(
2498                    &report(Some(altered)),
2499                    &knowledge,
2500                    &policy,
2501                    &subject(),
2502                    &state(&policy, &batch, batch.clone()),
2503                    false,
2504                )
2505                .is_err());
2506            }
2507            assert!(validate_value_free_authorization_commits(
2508                &report(Some(receipt)),
2509                &KnowledgeBase::new(),
2510                &policy,
2511                &subject(),
2512                &state(&policy, &batch, batch.clone()),
2513                false,
2514            )
2515            .is_err());
2516        }
2517    }
2518}