Skip to main content

termivar_scanner/
jwt_target_acceptance.rs

1//! Pure domain contracts for bounded JWT target-acceptance review.
2//!
3//! This module performs no I/O and retains no token bytes. It binds one
4//! operator-selected JSON marker to one exact-origin, application-contained
5//! GET resource, validates six ordered value-free leg facts, and derives one
6//! conservative audit conclusion. The two invalid-signature control legs are
7//! the only active legs and are admitted only after their same-stage valid and
8//! anonymous controls establish the prerequisite marker relationship.
9
10use std::{collections::BTreeSet, fmt};
11
12use serde::{Deserialize, Serialize};
13use thiserror::Error;
14use url::Url;
15
16/// Stable schema identifier for the value-free target-acceptance audit.
17pub const JWT_TARGET_ACCEPTANCE_AUDIT_SCHEMA: &str = "security.jwt-target-acceptance-audit/v1";
18/// Stable schema identifier for operator-supplied target policy documents.
19pub const JWT_TARGET_ACCEPTANCE_POLICY_SCHEMA: &str = "security.jwt-target-acceptance-policy/v1";
20/// Stable identifier for the exact-origin JSON-marker interpretation policy.
21pub const JWT_TARGET_ACCEPTANCE_POLICY_ID: &str =
22    "termivar.jwt-target-acceptance/exact-origin-json-marker-v1";
23
24/// Stable action identifier for valid-token baseline legs.
25pub const JWT_TARGET_ACCEPTANCE_VALID_BASELINE_ACTION_ID: &str =
26    "web.review.jwt-target-acceptance.valid-baseline@1";
27/// Stable action identifier for anonymous control legs.
28pub const JWT_TARGET_ACCEPTANCE_ANONYMOUS_CONTROL_ACTION_ID: &str =
29    "web.review.jwt-target-acceptance.anonymous-control@1";
30/// Stable action identifier for invalid-signature control legs.
31pub const JWT_TARGET_ACCEPTANCE_INVALID_CONTROL_ACTION_ID: &str =
32    "web.review.jwt-target-acceptance.invalid-control@1";
33/// Runtime-facing alias for the valid-token baseline action identifier.
34pub const JWT_TARGET_ACCEPTANCE_VALID_ACTION_ID: &str =
35    JWT_TARGET_ACCEPTANCE_VALID_BASELINE_ACTION_ID;
36/// Runtime-facing alias for the anonymous control action identifier.
37pub const JWT_TARGET_ACCEPTANCE_ANONYMOUS_ACTION_ID: &str =
38    JWT_TARGET_ACCEPTANCE_ANONYMOUS_CONTROL_ACTION_ID;
39/// Runtime-facing alias for the invalid-signature control action identifier.
40pub const JWT_TARGET_ACCEPTANCE_INVALID_ACTION_ID: &str =
41    JWT_TARGET_ACCEPTANCE_INVALID_CONTROL_ACTION_ID;
42
43/// Exact maximum number of role slots in one target-acceptance review.
44pub const MAX_JWT_TARGET_ACCEPTANCE_REQUESTS: u8 = 6;
45/// Maximum dispatched active invalid-signature control requests.
46pub const MAX_JWT_TARGET_ACCEPTANCE_ACTIVE_REQUESTS: u8 = 2;
47/// Maximum response bytes retained and interpreted for any one role.
48pub const MAX_JWT_TARGET_ACCEPTANCE_RESPONSE_BYTES: u64 = 64 * 1024;
49/// Maximum response bytes retained and interpreted across the complete review.
50pub const MAX_JWT_TARGET_ACCEPTANCE_TOTAL_RESPONSE_BYTES: u64 = 256 * 1024;
51/// Maximum bounded opaque evidence-reference bytes.
52pub const MAX_JWT_TARGET_ACCEPTANCE_EVIDENCE_REFERENCE_BYTES: usize = 128;
53
54const MAX_POLICY_REFERENCE_BYTES: usize = 64;
55const MAX_RESOURCE_REFERENCE_BYTES: usize = 64;
56const MAX_JSON_FIELD_BYTES: usize = 128;
57const JWT_TARGET_PREPARATION_BINDING_BYTES: usize = 32;
58const JWT_TARGET_PREPARATION_REFERENCE_PREFIX: &str = "jwt-target-preparation:";
59
60/// Opaque per-preparation identity that binds one local JWT review to the
61/// target-side audit produced from the same move-only handoff.
62///
63/// The bytes are random and are not derived from token material. The type is
64/// intentionally non-serializable and has no public constructor or byte
65/// accessor. A one-run opaque reference derived from the bytes may be projected
66/// into the v2 report solely to link its local and target audits; the raw bytes
67/// remain private and external callers may only move the value returned by the
68/// reviewed preparation boundary.
69#[derive(Clone, PartialEq, Eq)]
70pub(crate) struct JwtTargetAcceptanceBinding([u8; JWT_TARGET_PREPARATION_BINDING_BYTES]);
71
72impl JwtTargetAcceptanceBinding {
73    pub(crate) const fn from_random_bytes(
74        bytes: [u8; JWT_TARGET_PREPARATION_BINDING_BYTES],
75    ) -> Self {
76        Self(bytes)
77    }
78
79    pub(crate) fn reference(&self) -> String {
80        const HEX: &[u8; 16] = b"0123456789abcdef";
81        let mut reference = String::with_capacity(
82            JWT_TARGET_PREPARATION_REFERENCE_PREFIX.len()
83                + JWT_TARGET_PREPARATION_BINDING_BYTES * 2,
84        );
85        reference.push_str(JWT_TARGET_PREPARATION_REFERENCE_PREFIX);
86        for byte in self.0 {
87            reference.push(char::from(HEX[usize::from(byte >> 4)]));
88            reference.push(char::from(HEX[usize::from(byte & 0x0f)]));
89        }
90        reference
91    }
92
93    #[cfg(test)]
94    pub(crate) const fn synthetic_for_test(tag: u8) -> Self {
95        Self([tag; JWT_TARGET_PREPARATION_BINDING_BYTES])
96    }
97}
98
99impl fmt::Debug for JwtTargetAcceptanceBinding {
100    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
101        formatter.write_str("JwtTargetAcceptanceBinding(<opaque-per-preparation>)")
102    }
103}
104
105/// Fixed request method admitted by the target-acceptance policy.
106#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
107#[serde(rename_all = "snake_case")]
108#[non_exhaustive]
109pub enum JwtTargetAcceptanceMethod {
110    /// A bodyless HTTP GET request.
111    Get,
112}
113
114/// One strict, non-serializable target-selection policy.
115///
116/// Raw URLs and the selected JSON field stay private to this policy and are
117/// omitted from audit output. `Debug` redacts them. The policy grants no
118/// authority beyond its one exact resource.
119pub struct JwtTargetAcceptancePolicy {
120    operator_policy_reference: String,
121    operator_policy_revision: String,
122    application: Url,
123    resource: Url,
124    resource_reference: String,
125    success_json_field: String,
126}
127
128impl JwtTargetAcceptancePolicy {
129    /// Constructs one exact-origin, application-contained GET policy.
130    ///
131    /// Both URLs must use HTTP or HTTPS and must omit user information, query,
132    /// and fragment components. HTTP admission here does not grant network
133    /// authority; the owning runtime applies its existing transport policy.
134    /// Library callers must reject unsafe raw URL spellings before parsing,
135    /// because a typed [`Url`] can no longer expose spelling normalized by its
136    /// parser. The declared policy revision must change whenever the application,
137    /// resource, resource reference, or private success-marker semantics change;
138    /// reports omit the raw URL and marker and compare that revision instead.
139    pub fn new(
140        operator_policy_identity: (&str, &str),
141        application: Url,
142        resource: Url,
143        resource_reference: &str,
144        success_json_field: &str,
145    ) -> Result<Self, JwtTargetAcceptancePolicyError> {
146        let (operator_policy_reference, operator_policy_revision) = operator_policy_identity;
147        if !valid_slug(operator_policy_reference, MAX_POLICY_REFERENCE_BYTES) {
148            return Err(JwtTargetAcceptancePolicyError::InvalidOperatorPolicyReference);
149        }
150        if !valid_slug(operator_policy_revision, MAX_POLICY_REFERENCE_BYTES) {
151            return Err(JwtTargetAcceptancePolicyError::InvalidOperatorPolicyRevision);
152        }
153        validate_http_url(&application)
154            .map_err(|_| JwtTargetAcceptancePolicyError::InvalidApplication)?;
155        validate_http_url(&resource)
156            .map_err(|_| JwtTargetAcceptancePolicyError::InvalidResource)?;
157        if !same_exact_origin(&application, &resource) {
158            return Err(JwtTargetAcceptancePolicyError::ForeignResourceOrigin);
159        }
160        if !path_within_application(&application, &resource) {
161            return Err(JwtTargetAcceptancePolicyError::ResourceOutsideApplication);
162        }
163        if !valid_slug(resource_reference, MAX_RESOURCE_REFERENCE_BYTES) {
164            return Err(JwtTargetAcceptancePolicyError::InvalidResourceReference);
165        }
166        if !valid_json_field(success_json_field) {
167            return Err(JwtTargetAcceptancePolicyError::InvalidSuccessJsonField);
168        }
169
170        Ok(Self {
171            operator_policy_reference: operator_policy_reference.to_owned(),
172            operator_policy_revision: operator_policy_revision.to_owned(),
173            application,
174            resource,
175            resource_reference: resource_reference.to_owned(),
176            success_json_field: success_json_field.to_owned(),
177        })
178    }
179
180    /// Stable schema identifier for the selected operator policy.
181    pub const fn schema(&self) -> &'static str {
182        JWT_TARGET_ACCEPTANCE_POLICY_SCHEMA
183    }
184
185    /// Stable interpretation-policy identifier.
186    pub const fn policy_id(&self) -> &'static str {
187        JWT_TARGET_ACCEPTANCE_POLICY_ID
188    }
189
190    /// Fixed bodyless request method.
191    pub const fn method(&self) -> JwtTargetAcceptanceMethod {
192        JwtTargetAcceptanceMethod::Get
193    }
194
195    /// Safe non-secret operator policy reference.
196    pub fn operator_policy_reference(&self) -> &str {
197        &self.operator_policy_reference
198    }
199
200    /// Safe non-secret operator policy revision.
201    pub fn operator_policy_revision(&self) -> &str {
202        &self.operator_policy_revision
203    }
204
205    /// Exact selected application URL.
206    ///
207    /// The value is runtime input and is never copied into the value-free
208    /// audit.
209    pub const fn application(&self) -> &Url {
210        &self.application
211    }
212
213    /// Exact selected GET resource URL.
214    ///
215    /// The value is runtime input and is never copied into the value-free
216    /// audit.
217    pub const fn resource(&self) -> &Url {
218        &self.resource
219    }
220
221    /// Safe non-secret resource reference used by audit output.
222    pub fn resource_reference(&self) -> &str {
223        &self.resource_reference
224    }
225
226    /// Private top-level JSON marker selected by the operator.
227    ///
228    /// The field name is exposed only for response classification and is
229    /// never copied into the audit.
230    pub fn success_json_field(&self) -> &str {
231        &self.success_json_field
232    }
233
234    /// Fixed request ceiling.
235    pub const fn max_requests(&self) -> u8 {
236        MAX_JWT_TARGET_ACCEPTANCE_REQUESTS
237    }
238
239    /// Fixed active-request ceiling.
240    pub const fn max_active_requests(&self) -> u8 {
241        MAX_JWT_TARGET_ACCEPTANCE_ACTIVE_REQUESTS
242    }
243
244    /// Fixed per-response retained/interpreted byte ceiling.
245    pub const fn max_response_bytes(&self) -> u64 {
246        MAX_JWT_TARGET_ACCEPTANCE_RESPONSE_BYTES
247    }
248
249    /// Fixed aggregate retained/interpreted byte ceiling.
250    pub const fn max_total_response_bytes(&self) -> u64 {
251        MAX_JWT_TARGET_ACCEPTANCE_TOTAL_RESPONSE_BYTES
252    }
253}
254
255impl fmt::Debug for JwtTargetAcceptancePolicy {
256    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
257        formatter
258            .debug_struct("JwtTargetAcceptancePolicy")
259            .field("operator_policy_reference", &self.operator_policy_reference)
260            .field("operator_policy_revision", &self.operator_policy_revision)
261            .field("application", &"<redacted>")
262            .field("resource", &"<redacted>")
263            .field("resource_reference", &self.resource_reference)
264            .field("success_json_field", &"<redacted>")
265            .field("method", &JwtTargetAcceptanceMethod::Get)
266            .finish()
267    }
268}
269
270/// Static, redaction-safe target policy failure.
271#[derive(Debug, Clone, Copy, PartialEq, Eq, Error)]
272#[non_exhaustive]
273pub enum JwtTargetAcceptancePolicyError {
274    /// The operator policy reference was not a bounded lowercase ASCII slug.
275    #[error("JWT target-acceptance operator policy reference is invalid")]
276    InvalidOperatorPolicyReference,
277    /// The operator policy revision was not a bounded lowercase ASCII slug.
278    #[error("JWT target-acceptance operator policy revision is invalid")]
279    InvalidOperatorPolicyRevision,
280    /// The application URL violated the strict HTTP(S) URL shape.
281    #[error("JWT target-acceptance application URL is invalid")]
282    InvalidApplication,
283    /// The resource URL violated the strict HTTP(S) URL shape.
284    #[error("JWT target-acceptance resource URL is invalid")]
285    InvalidResource,
286    /// The resource scheme, host, or effective port differed from the application.
287    #[error("JWT target-acceptance resource has a foreign origin")]
288    ForeignResourceOrigin,
289    /// The resource path was not segment-contained by the application path.
290    #[error("JWT target-acceptance resource is outside the application path")]
291    ResourceOutsideApplication,
292    /// The resource reference was not a bounded lowercase ASCII slug.
293    #[error("JWT target-acceptance resource reference is invalid")]
294    InvalidResourceReference,
295    /// The selected top-level JSON field was not a bounded safe ASCII name.
296    #[error("JWT target-acceptance JSON marker field is invalid")]
297    InvalidSuccessJsonField,
298}
299
300fn valid_slug(value: &str, maximum: usize) -> bool {
301    !value.is_empty()
302        && value.len() <= maximum
303        && value.bytes().enumerate().all(|(index, byte)| {
304            byte.is_ascii_lowercase()
305                || byte.is_ascii_digit()
306                || (index > 0 && matches!(byte, b'-' | b'_' | b'.'))
307        })
308}
309
310fn valid_json_field(value: &str) -> bool {
311    !value.is_empty()
312        && value.len() <= MAX_JSON_FIELD_BYTES
313        && value.bytes().enumerate().all(|(index, byte)| {
314            byte.is_ascii_alphanumeric()
315                || matches!(byte, b'_' | b'-')
316                || (index > 0 && matches!(byte, b'.' | b':'))
317        })
318}
319
320fn validate_http_url(url: &Url) -> Result<(), ()> {
321    if !matches!(url.scheme(), "http" | "https")
322        || url.host().is_none()
323        || url.port_or_known_default().is_none()
324        || !url.username().is_empty()
325        || url.password().is_some()
326        || url.query().is_some()
327        || url.fragment().is_some()
328        || !url.path().starts_with('/')
329        || url.path().contains('%')
330        || url.path().contains('\\')
331        || url.path().contains("//")
332    {
333        return Err(());
334    }
335    Ok(())
336}
337
338fn same_exact_origin(application: &Url, resource: &Url) -> bool {
339    application.scheme() == resource.scheme()
340        && application.host() == resource.host()
341        && application.port_or_known_default() == resource.port_or_known_default()
342}
343
344fn path_within_application(application: &Url, resource: &Url) -> bool {
345    let application_path = application.path();
346    let resource_path = resource.path();
347    if application_path == "/" {
348        return true;
349    }
350    let boundary = application_path.trim_end_matches('/');
351    resource_path == boundary
352        || resource_path
353            .strip_prefix(boundary)
354            .is_some_and(|suffix| suffix.starts_with('/'))
355}
356
357/// Candidate/replay stage for one ordered role.
358#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
359#[serde(rename_all = "snake_case")]
360#[non_exhaustive]
361pub enum JwtTargetAcceptanceLegStage {
362    /// First observation in a pair.
363    Candidate,
364    /// Stability replay in a pair.
365    Replay,
366}
367
368/// Passive/active classification derived from a role.
369#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
370#[serde(rename_all = "snake_case")]
371#[non_exhaustive]
372pub enum JwtTargetAcceptanceActivity {
373    /// A valid-token baseline or anonymous control.
374    Passive,
375    /// An invalid-signature control request.
376    Active,
377}
378
379/// Exact closed order of target-acceptance request roles.
380#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
381#[serde(rename_all = "snake_case")]
382#[non_exhaustive]
383pub enum JwtTargetAcceptanceLegRole {
384    /// Valid token, first observation.
385    ValidCandidate,
386    /// No Authorization value, first observation.
387    AnonymousCandidate,
388    /// Invalid-signature token, first observation.
389    InvalidCandidate,
390    /// Valid token replay.
391    ValidReplay,
392    /// No Authorization value, replay.
393    AnonymousReplay,
394    /// Invalid-signature token replay.
395    InvalidReplay,
396}
397
398impl JwtTargetAcceptanceLegRole {
399    /// Required order for all aggregate audits.
400    pub const ORDERED: [Self; MAX_JWT_TARGET_ACCEPTANCE_REQUESTS as usize] = [
401        Self::ValidCandidate,
402        Self::AnonymousCandidate,
403        Self::InvalidCandidate,
404        Self::ValidReplay,
405        Self::AnonymousReplay,
406        Self::InvalidReplay,
407    ];
408
409    /// Stable action identifier used for this role.
410    pub const fn action_id(self) -> &'static str {
411        match self {
412            Self::ValidCandidate | Self::ValidReplay => {
413                JWT_TARGET_ACCEPTANCE_VALID_BASELINE_ACTION_ID
414            },
415            Self::AnonymousCandidate | Self::AnonymousReplay => {
416                JWT_TARGET_ACCEPTANCE_ANONYMOUS_CONTROL_ACTION_ID
417            },
418            Self::InvalidCandidate | Self::InvalidReplay => {
419                JWT_TARGET_ACCEPTANCE_INVALID_CONTROL_ACTION_ID
420            },
421        }
422    }
423
424    /// Candidate/replay stage associated with this role.
425    pub const fn stage(self) -> JwtTargetAcceptanceLegStage {
426        match self {
427            Self::ValidCandidate | Self::AnonymousCandidate | Self::InvalidCandidate => {
428                JwtTargetAcceptanceLegStage::Candidate
429            },
430            Self::ValidReplay | Self::AnonymousReplay | Self::InvalidReplay => {
431                JwtTargetAcceptanceLegStage::Replay
432            },
433        }
434    }
435
436    /// Passive/active classification associated with this role.
437    pub const fn activity(self) -> JwtTargetAcceptanceActivity {
438        if self.is_active() {
439            JwtTargetAcceptanceActivity::Active
440        } else {
441            JwtTargetAcceptanceActivity::Passive
442        }
443    }
444
445    /// Whether the role uses an invalid-signature control token.
446    pub const fn is_active(self) -> bool {
447        matches!(self, Self::InvalidCandidate | Self::InvalidReplay)
448    }
449
450    /// Whether an Authorization value is required for the role.
451    pub const fn requires_authorization(self) -> bool {
452        !self.is_anonymous()
453    }
454
455    /// Whether the role uses the supplied valid token.
456    pub const fn uses_valid_token(self) -> bool {
457        matches!(self, Self::ValidCandidate | Self::ValidReplay)
458    }
459
460    /// Whether the role uses the invalid-signature control token.
461    pub const fn uses_invalid_token(self) -> bool {
462        self.is_active()
463    }
464
465    /// Whether the role omits Authorization.
466    pub const fn is_anonymous(self) -> bool {
467        matches!(self, Self::AnonymousCandidate | Self::AnonymousReplay)
468    }
469}
470
471/// Whether a leg was dispatched by the owning runtime.
472#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
473#[serde(rename_all = "snake_case")]
474#[non_exhaustive]
475pub enum JwtTargetAcceptanceDispatchStatus {
476    /// No target request was sent for the role.
477    NotDispatched,
478    /// One target request was sent for the role.
479    Dispatched,
480}
481
482/// Whether response evidence was committed for a dispatched leg.
483#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
484#[serde(rename_all = "snake_case")]
485#[non_exhaustive]
486pub enum JwtTargetAcceptanceCommitStatus {
487    /// No response evidence was committed.
488    NotCommitted,
489    /// Response evidence was committed exactly once.
490    Committed,
491}
492
493/// Completeness and JSON-marker classification state for one response.
494#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
495#[serde(rename_all = "snake_case")]
496#[non_exhaustive]
497pub enum JwtTargetAcceptanceResponseStatus {
498    /// No complete response classification is available.
499    NotClassified,
500    /// The response was complete and the selected top-level marker was classified.
501    CompleteAndClassified,
502    /// The response was incomplete and the marker was not evaluated.
503    Incomplete,
504}
505
506/// Value-free observation of the one privately selected JSON marker.
507#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
508#[serde(rename_all = "snake_case")]
509#[non_exhaustive]
510pub enum JwtTargetAcceptanceMarkerStatus {
511    /// The selected top-level marker was observed.
512    Observed,
513    /// The selected top-level marker was not observed.
514    NotObserved,
515    /// No complete marker classification was available.
516    NotEvaluated,
517}
518
519/// Value-free facts for exactly one ordered request role.
520#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
521#[serde(deny_unknown_fields)]
522pub struct JwtTargetAcceptanceLegFact {
523    role: JwtTargetAcceptanceLegRole,
524    dispatched: JwtTargetAcceptanceDispatchStatus,
525    committed: JwtTargetAcceptanceCommitStatus,
526    response_status: JwtTargetAcceptanceResponseStatus,
527    marker_status: JwtTargetAcceptanceMarkerStatus,
528    retained_response_bytes: u64,
529    accounted_response_bytes: u64,
530    evidence_reference: Option<String>,
531}
532
533impl JwtTargetAcceptanceLegFact {
534    /// Constructs and validates one value-free leg fact.
535    pub fn new(
536        role: JwtTargetAcceptanceLegRole,
537        dispatched: JwtTargetAcceptanceDispatchStatus,
538        committed: JwtTargetAcceptanceCommitStatus,
539        response_status: JwtTargetAcceptanceResponseStatus,
540        marker_status: JwtTargetAcceptanceMarkerStatus,
541        accounted_response_bytes: u64,
542        evidence_reference: Option<String>,
543    ) -> Result<Self, JwtTargetAcceptanceLegFactError> {
544        Self::new_with_transport_accounting(
545            role,
546            dispatched,
547            committed,
548            response_status,
549            marker_status,
550            accounted_response_bytes,
551            accounted_response_bytes,
552            evidence_reference,
553        )
554    }
555
556    /// Constructs one fact while retaining both the bounded body bytes and
557    /// the broker's exact transport accounting. A delivered transport chunk
558    /// may cross the retention ceiling; those extra bytes remain charged but
559    /// are never retained or interpreted.
560    #[allow(clippy::too_many_arguments)]
561    pub fn new_with_transport_accounting(
562        role: JwtTargetAcceptanceLegRole,
563        dispatched: JwtTargetAcceptanceDispatchStatus,
564        committed: JwtTargetAcceptanceCommitStatus,
565        response_status: JwtTargetAcceptanceResponseStatus,
566        marker_status: JwtTargetAcceptanceMarkerStatus,
567        retained_response_bytes: u64,
568        accounted_response_bytes: u64,
569        evidence_reference: Option<String>,
570    ) -> Result<Self, JwtTargetAcceptanceLegFactError> {
571        let fact = Self {
572            role,
573            dispatched,
574            committed,
575            response_status,
576            marker_status,
577            retained_response_bytes,
578            accounted_response_bytes,
579            evidence_reference,
580        };
581        fact.validate()?;
582        Ok(fact)
583    }
584
585    /// Creates the only valid undispatched placeholder for a role.
586    pub fn not_dispatched(role: JwtTargetAcceptanceLegRole) -> Self {
587        Self {
588            role,
589            dispatched: JwtTargetAcceptanceDispatchStatus::NotDispatched,
590            committed: JwtTargetAcceptanceCommitStatus::NotCommitted,
591            response_status: JwtTargetAcceptanceResponseStatus::NotClassified,
592            marker_status: JwtTargetAcceptanceMarkerStatus::NotEvaluated,
593            retained_response_bytes: 0,
594            accounted_response_bytes: 0,
595            evidence_reference: None,
596        }
597    }
598
599    /// Ordered role represented by this fact.
600    pub const fn role(&self) -> JwtTargetAcceptanceLegRole {
601        self.role
602    }
603
604    /// Dispatch state.
605    pub const fn dispatch_status(&self) -> JwtTargetAcceptanceDispatchStatus {
606        self.dispatched
607    }
608
609    /// Whether one request was dispatched.
610    pub const fn was_dispatched(&self) -> bool {
611        matches!(
612            self.dispatched,
613            JwtTargetAcceptanceDispatchStatus::Dispatched
614        )
615    }
616
617    /// Evidence-commit state.
618    pub const fn commit_status(&self) -> JwtTargetAcceptanceCommitStatus {
619        self.committed
620    }
621
622    /// Whether response evidence was committed.
623    pub const fn was_committed(&self) -> bool {
624        matches!(self.committed, JwtTargetAcceptanceCommitStatus::Committed)
625    }
626
627    /// Response completeness/classification state.
628    pub const fn response_status(&self) -> JwtTargetAcceptanceResponseStatus {
629        self.response_status
630    }
631
632    /// Selected-marker observation state.
633    pub const fn marker_status(&self) -> JwtTargetAcceptanceMarkerStatus {
634        self.marker_status
635    }
636
637    /// Response bytes retained for bounded classification.
638    pub const fn retained_response_bytes(&self) -> u64 {
639        self.retained_response_bytes
640    }
641
642    /// Exact response bytes charged by the shared transport authority.
643    pub const fn accounted_response_bytes(&self) -> u64 {
644        self.accounted_response_bytes
645    }
646
647    /// Optional bounded opaque evidence reference.
648    pub fn evidence_reference(&self) -> Option<&str> {
649        self.evidence_reference.as_deref()
650    }
651
652    fn is_complete_classification(&self) -> bool {
653        self.was_dispatched()
654            && self.was_committed()
655            && self.response_status == JwtTargetAcceptanceResponseStatus::CompleteAndClassified
656            && matches!(
657                self.marker_status,
658                JwtTargetAcceptanceMarkerStatus::Observed
659                    | JwtTargetAcceptanceMarkerStatus::NotObserved
660            )
661    }
662
663    fn validate(&self) -> Result<(), JwtTargetAcceptanceLegFactError> {
664        if self.retained_response_bytes > MAX_JWT_TARGET_ACCEPTANCE_RESPONSE_BYTES {
665            return Err(JwtTargetAcceptanceLegFactError::ResponseBytesExceeded);
666        }
667        if self.accounted_response_bytes < self.retained_response_bytes {
668            return Err(JwtTargetAcceptanceLegFactError::TransportBytesBelowRetained);
669        }
670        if let Some(reference) = self.evidence_reference.as_deref() {
671            if !valid_evidence_reference(reference) {
672                return Err(JwtTargetAcceptanceLegFactError::InvalidEvidenceReference);
673            }
674        }
675
676        let valid = match (self.dispatched, self.committed) {
677            (
678                JwtTargetAcceptanceDispatchStatus::NotDispatched,
679                JwtTargetAcceptanceCommitStatus::NotCommitted,
680            ) => {
681                self.response_status == JwtTargetAcceptanceResponseStatus::NotClassified
682                    && self.marker_status == JwtTargetAcceptanceMarkerStatus::NotEvaluated
683                    && self.retained_response_bytes == 0
684                    && self.accounted_response_bytes == 0
685                    && self.evidence_reference.is_none()
686            },
687            (
688                JwtTargetAcceptanceDispatchStatus::Dispatched,
689                JwtTargetAcceptanceCommitStatus::NotCommitted,
690            ) => {
691                self.response_status != JwtTargetAcceptanceResponseStatus::CompleteAndClassified
692                    && self.marker_status == JwtTargetAcceptanceMarkerStatus::NotEvaluated
693                    && self.evidence_reference.is_none()
694            },
695            (
696                JwtTargetAcceptanceDispatchStatus::Dispatched,
697                JwtTargetAcceptanceCommitStatus::Committed,
698            ) => {
699                self.evidence_reference.is_some()
700                    && match self.response_status {
701                        JwtTargetAcceptanceResponseStatus::CompleteAndClassified => matches!(
702                            self.marker_status,
703                            JwtTargetAcceptanceMarkerStatus::Observed
704                                | JwtTargetAcceptanceMarkerStatus::NotObserved
705                        ),
706                        JwtTargetAcceptanceResponseStatus::NotClassified
707                        | JwtTargetAcceptanceResponseStatus::Incomplete => {
708                            self.marker_status == JwtTargetAcceptanceMarkerStatus::NotEvaluated
709                        },
710                    }
711            },
712            (
713                JwtTargetAcceptanceDispatchStatus::NotDispatched,
714                JwtTargetAcceptanceCommitStatus::Committed,
715            ) => false,
716        };
717
718        if valid {
719            Ok(())
720        } else {
721            Err(JwtTargetAcceptanceLegFactError::InconsistentState)
722        }
723    }
724}
725
726fn valid_evidence_reference(value: &str) -> bool {
727    !value.is_empty()
728        && value.len() <= MAX_JWT_TARGET_ACCEPTANCE_EVIDENCE_REFERENCE_BYTES
729        && value.bytes().all(|byte| byte.is_ascii_graphic())
730}
731
732/// Static, redaction-safe leg construction failure.
733#[derive(Debug, Clone, Copy, PartialEq, Eq, Error, Serialize, Deserialize)]
734#[serde(rename_all = "snake_case")]
735#[non_exhaustive]
736pub enum JwtTargetAcceptanceLegFactError {
737    /// Dispatch, commit, classification, marker, byte, and evidence states disagreed.
738    #[error("JWT target-acceptance leg state is inconsistent")]
739    InconsistentState,
740    /// The leg exceeded the fixed per-response retention ceiling.
741    #[error("JWT target-acceptance leg exceeds its response-byte limit")]
742    ResponseBytesExceeded,
743    /// Exact transport accounting was smaller than the retained body.
744    #[error("JWT target-acceptance transport bytes cannot be below retained bytes")]
745    TransportBytesBelowRetained,
746    /// The optional evidence reference was empty, unsafe, or oversized.
747    #[error("JWT target-acceptance evidence reference is invalid")]
748    InvalidEvidenceReference,
749}
750
751/// Stable candidate/replay relationship for one marker dimension.
752#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
753#[serde(rename_all = "snake_case")]
754#[non_exhaustive]
755pub enum JwtTargetAcceptanceDimension {
756    /// Candidate and replay both observed the selected marker.
757    ObservedStable,
758    /// Candidate and replay both did not observe the selected marker.
759    NotObservedStable,
760    /// Candidate and replay produced different complete marker observations.
761    Unstable,
762    /// Both role slots were deliberately not dispatched.
763    NotEvaluated,
764    /// At least one role slot lacked a complete classified response.
765    Incomplete,
766}
767
768/// Value-free reason the target portion was not eligible to run.
769#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
770#[serde(rename_all = "snake_case")]
771#[non_exhaustive]
772pub enum JwtTargetAcceptanceNotEligibleReason {
773    /// A supported local compact-token parse was not established.
774    LocalParsingNotEstablished,
775    /// Local expected-claim policy consistency was not established.
776    LocalPolicyNotEstablished,
777    /// Local signature validity against the selected key was not established.
778    LocalSignatureNotEstablished,
779    /// The required target runtime authority was unavailable.
780    RuntimeAuthorityUnavailable,
781    /// The bounded target request budget was unavailable.
782    RequestBudgetUnavailable,
783}
784
785/// Typed reason a complete target conclusion was unavailable.
786#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
787#[serde(rename_all = "snake_case")]
788#[non_exhaustive]
789pub enum JwtTargetAcceptanceIncompleteReason {
790    /// Local or runtime eligibility was not established before target work.
791    NotEligible(JwtTargetAcceptanceNotEligibleReason),
792    /// One required role was never dispatched.
793    LegNotDispatched { role: JwtTargetAcceptanceLegRole },
794    /// A dispatched role did not commit response evidence.
795    LegNotCommitted { role: JwtTargetAcceptanceLegRole },
796    /// A committed response was incomplete.
797    ResponseIncomplete { role: JwtTargetAcceptanceLegRole },
798    /// A committed response was not classified for the selected marker.
799    ResponseNotClassified { role: JwtTargetAcceptanceLegRole },
800}
801
802/// Conservative aggregate interpretation of the three paired dimensions.
803#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
804#[serde(rename_all = "snake_case")]
805#[non_exhaustive]
806pub enum JwtTargetAcceptanceConclusion {
807    /// Valid observations were stable, anonymous controls were stably absent,
808    /// and invalid-signature controls stably observed the selected marker.
809    InvalidSignatureControlMarkerObservedWithAnonymousControl,
810    /// Anonymous controls stably observed the selected marker, suppressing any
811    /// invalid-signature-specific interpretation.
812    PublicOrTokenAgnosticMarkerObserved,
813    /// Valid observations were stable, anonymous controls were stably absent,
814    /// and invalid-signature controls stably lacked the selected marker.
815    InvalidControlMarkerNotObserved,
816    /// At least one complete candidate/replay pair disagreed.
817    UnstableInconclusive,
818    /// All required facts were complete and stable but did not match a more
819    /// specific relationship.
820    Inconclusive,
821    /// Required facts or eligibility were incomplete for a stable conclusion.
822    Incomplete(JwtTargetAcceptanceIncompleteReason),
823}
824
825/// Derived integer accounting for one aggregate audit.
826#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
827pub struct JwtTargetAcceptanceAccounting {
828    dispatched_request_count: u8,
829    dispatched_passive_request_count: u8,
830    dispatched_active_request_count: u8,
831    committed_response_count: u8,
832    retained_response_bytes: u64,
833    accounted_response_bytes: u64,
834}
835
836impl JwtTargetAcceptanceAccounting {
837    /// Total dispatched target requests.
838    pub const fn dispatched_request_count(self) -> u8 {
839        self.dispatched_request_count
840    }
841
842    /// Dispatched valid and anonymous legs.
843    pub const fn dispatched_passive_request_count(self) -> u8 {
844        self.dispatched_passive_request_count
845    }
846
847    /// Dispatched invalid-signature control legs.
848    pub const fn dispatched_active_request_count(self) -> u8 {
849        self.dispatched_active_request_count
850    }
851
852    /// Responses whose evidence was committed.
853    pub const fn committed_response_count(self) -> u8 {
854        self.committed_response_count
855    }
856
857    /// Total body bytes retained and interpreted across all roles.
858    pub const fn retained_response_bytes(self) -> u64 {
859        self.retained_response_bytes
860    }
861
862    /// Total response bytes charged across all roles.
863    pub const fn accounted_response_bytes(self) -> u64 {
864        self.accounted_response_bytes
865    }
866}
867
868/// Strict value-free aggregate audit for the target portion.
869#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
870pub struct JwtTargetAcceptanceAudit {
871    schema: &'static str,
872    policy: &'static str,
873    operator_policy_reference: String,
874    operator_policy_revision: String,
875    resource_reference: String,
876    method: JwtTargetAcceptanceMethod,
877    legs: Vec<JwtTargetAcceptanceLegFact>,
878    accounting: JwtTargetAcceptanceAccounting,
879    valid_marker: JwtTargetAcceptanceDimension,
880    anonymous_marker: JwtTargetAcceptanceDimension,
881    invalid_marker: JwtTargetAcceptanceDimension,
882    conclusion: JwtTargetAcceptanceConclusion,
883    #[serde(skip)]
884    preparation_binding: Option<JwtTargetAcceptanceBinding>,
885}
886
887impl JwtTargetAcceptanceAudit {
888    /// Validates six exact ordered roles and derives a strict aggregate audit.
889    pub fn from_legs(
890        policy: &JwtTargetAcceptancePolicy,
891        legs: Vec<JwtTargetAcceptanceLegFact>,
892    ) -> Result<Self, JwtTargetAcceptanceAuditError> {
893        if legs.len() != usize::from(MAX_JWT_TARGET_ACCEPTANCE_REQUESTS) {
894            return Err(JwtTargetAcceptanceAuditError::UnexpectedLegCount);
895        }
896        for (index, (fact, expected_role)) in legs
897            .iter()
898            .zip(JwtTargetAcceptanceLegRole::ORDERED)
899            .enumerate()
900        {
901            if fact.role != expected_role {
902                return Err(JwtTargetAcceptanceAuditError::UnexpectedRoleOrder {
903                    ordinal: u8::try_from(index).expect("six roles fit u8"),
904                });
905            }
906            fact.validate()
907                .map_err(|reason| JwtTargetAcceptanceAuditError::InvalidLeg {
908                    role: fact.role,
909                    reason,
910                })?;
911        }
912
913        validate_active_prerequisite(&legs, 0, 1, 2)?;
914        validate_active_prerequisite(&legs, 3, 4, 5)?;
915        validate_sequential_lifecycle(&legs)?;
916
917        let mut evidence_references = BTreeSet::new();
918        let mut dispatched_request_count = 0_u8;
919        let mut dispatched_passive_request_count = 0_u8;
920        let mut dispatched_active_request_count = 0_u8;
921        let mut committed_response_count = 0_u8;
922        let mut retained_response_bytes = 0_u64;
923        let mut accounted_response_bytes = 0_u64;
924        for fact in &legs {
925            if fact.was_dispatched() {
926                dispatched_request_count = dispatched_request_count
927                    .checked_add(1)
928                    .ok_or(JwtTargetAcceptanceAuditError::RequestCountExceeded)?;
929                if fact.role.is_active() {
930                    dispatched_active_request_count = dispatched_active_request_count
931                        .checked_add(1)
932                        .ok_or(JwtTargetAcceptanceAuditError::ActiveRequestCountExceeded)?;
933                } else {
934                    dispatched_passive_request_count = dispatched_passive_request_count
935                        .checked_add(1)
936                        .ok_or(JwtTargetAcceptanceAuditError::RequestCountExceeded)?;
937                }
938            }
939            if fact.was_committed() {
940                committed_response_count = committed_response_count
941                    .checked_add(1)
942                    .ok_or(JwtTargetAcceptanceAuditError::RequestCountExceeded)?;
943            }
944            retained_response_bytes = retained_response_bytes
945                .checked_add(fact.retained_response_bytes)
946                .ok_or(JwtTargetAcceptanceAuditError::TotalResponseBytesExceeded)?;
947            accounted_response_bytes = accounted_response_bytes
948                .checked_add(fact.accounted_response_bytes)
949                .ok_or(JwtTargetAcceptanceAuditError::TransportResponseBytesOverflow)?;
950            if let Some(reference) = fact.evidence_reference.as_deref() {
951                if !evidence_references.insert(reference) {
952                    return Err(JwtTargetAcceptanceAuditError::DuplicateEvidenceReference);
953                }
954            }
955        }
956        if dispatched_request_count > MAX_JWT_TARGET_ACCEPTANCE_REQUESTS {
957            return Err(JwtTargetAcceptanceAuditError::RequestCountExceeded);
958        }
959        if dispatched_active_request_count > MAX_JWT_TARGET_ACCEPTANCE_ACTIVE_REQUESTS {
960            return Err(JwtTargetAcceptanceAuditError::ActiveRequestCountExceeded);
961        }
962        if retained_response_bytes > MAX_JWT_TARGET_ACCEPTANCE_TOTAL_RESPONSE_BYTES {
963            return Err(JwtTargetAcceptanceAuditError::TotalResponseBytesExceeded);
964        }
965
966        let accounting = JwtTargetAcceptanceAccounting {
967            dispatched_request_count,
968            dispatched_passive_request_count,
969            dispatched_active_request_count,
970            committed_response_count,
971            retained_response_bytes,
972            accounted_response_bytes,
973        };
974        let valid_marker = pair_dimension(&legs[0], &legs[3]);
975        let anonymous_marker = pair_dimension(&legs[1], &legs[4]);
976        let invalid_marker = pair_dimension(&legs[2], &legs[5]);
977        let conclusion = derive_conclusion(&legs, valid_marker, anonymous_marker, invalid_marker);
978
979        Ok(Self::new_value_free(
980            policy,
981            legs,
982            accounting,
983            valid_marker,
984            anonymous_marker,
985            invalid_marker,
986            conclusion,
987        ))
988    }
989
990    /// Creates a zero-request audit when prerequisites are not established.
991    pub fn not_eligible(
992        policy: &JwtTargetAcceptancePolicy,
993        reason: JwtTargetAcceptanceNotEligibleReason,
994    ) -> Self {
995        Self::new_value_free(
996            policy,
997            Vec::new(),
998            JwtTargetAcceptanceAccounting {
999                dispatched_request_count: 0,
1000                dispatched_passive_request_count: 0,
1001                dispatched_active_request_count: 0,
1002                committed_response_count: 0,
1003                retained_response_bytes: 0,
1004                accounted_response_bytes: 0,
1005            },
1006            JwtTargetAcceptanceDimension::NotEvaluated,
1007            JwtTargetAcceptanceDimension::NotEvaluated,
1008            JwtTargetAcceptanceDimension::NotEvaluated,
1009            JwtTargetAcceptanceConclusion::Incomplete(
1010                JwtTargetAcceptanceIncompleteReason::NotEligible(reason),
1011            ),
1012        )
1013    }
1014
1015    fn new_value_free(
1016        policy: &JwtTargetAcceptancePolicy,
1017        legs: Vec<JwtTargetAcceptanceLegFact>,
1018        accounting: JwtTargetAcceptanceAccounting,
1019        valid_marker: JwtTargetAcceptanceDimension,
1020        anonymous_marker: JwtTargetAcceptanceDimension,
1021        invalid_marker: JwtTargetAcceptanceDimension,
1022        conclusion: JwtTargetAcceptanceConclusion,
1023    ) -> Self {
1024        Self {
1025            schema: JWT_TARGET_ACCEPTANCE_AUDIT_SCHEMA,
1026            policy: JWT_TARGET_ACCEPTANCE_POLICY_ID,
1027            operator_policy_reference: policy.operator_policy_reference.clone(),
1028            operator_policy_revision: policy.operator_policy_revision.clone(),
1029            resource_reference: policy.resource_reference.clone(),
1030            method: JwtTargetAcceptanceMethod::Get,
1031            legs,
1032            accounting,
1033            valid_marker,
1034            anonymous_marker,
1035            invalid_marker,
1036            conclusion,
1037            preparation_binding: None,
1038        }
1039    }
1040
1041    pub(crate) fn bind_to_preparation(mut self, binding: JwtTargetAcceptanceBinding) -> Self {
1042        self.preparation_binding = Some(binding);
1043        self
1044    }
1045
1046    pub(crate) const fn preparation_binding(&self) -> Option<&JwtTargetAcceptanceBinding> {
1047        self.preparation_binding.as_ref()
1048    }
1049
1050    /// Stable audit schema identifier.
1051    pub const fn schema(&self) -> &'static str {
1052        JWT_TARGET_ACCEPTANCE_AUDIT_SCHEMA
1053    }
1054
1055    /// Stable interpretation-policy identifier.
1056    pub const fn policy(&self) -> &'static str {
1057        JWT_TARGET_ACCEPTANCE_POLICY_ID
1058    }
1059
1060    /// Safe non-secret operator policy reference.
1061    pub fn operator_policy_reference(&self) -> &str {
1062        &self.operator_policy_reference
1063    }
1064
1065    /// Safe non-secret operator policy revision.
1066    pub fn operator_policy_revision(&self) -> &str {
1067        &self.operator_policy_revision
1068    }
1069
1070    /// Safe non-secret selected-resource reference.
1071    pub fn resource_reference(&self) -> &str {
1072        &self.resource_reference
1073    }
1074
1075    /// Fixed request method.
1076    pub const fn method(&self) -> JwtTargetAcceptanceMethod {
1077        self.method
1078    }
1079
1080    /// Ordered value-free leg facts. This is empty only for `not_eligible`.
1081    pub fn legs(&self) -> &[JwtTargetAcceptanceLegFact] {
1082        &self.legs
1083    }
1084
1085    /// Derived integer accounting.
1086    pub const fn accounting(&self) -> JwtTargetAcceptanceAccounting {
1087        self.accounting
1088    }
1089
1090    /// Stable valid-token marker dimension.
1091    pub const fn valid_marker(&self) -> JwtTargetAcceptanceDimension {
1092        self.valid_marker
1093    }
1094
1095    /// Stable anonymous marker dimension.
1096    pub const fn anonymous_marker(&self) -> JwtTargetAcceptanceDimension {
1097        self.anonymous_marker
1098    }
1099
1100    /// Stable invalid-signature control marker dimension.
1101    pub const fn invalid_marker(&self) -> JwtTargetAcceptanceDimension {
1102        self.invalid_marker
1103    }
1104
1105    /// Conservative aggregate conclusion.
1106    pub const fn conclusion(&self) -> JwtTargetAcceptanceConclusion {
1107        self.conclusion
1108    }
1109}
1110
1111fn validate_active_prerequisite(
1112    legs: &[JwtTargetAcceptanceLegFact],
1113    valid_index: usize,
1114    anonymous_index: usize,
1115    invalid_index: usize,
1116) -> Result<(), JwtTargetAcceptanceAuditError> {
1117    let invalid = &legs[invalid_index];
1118    if invalid.was_dispatched()
1119        && !active_prerequisite_is_established(legs, valid_index, anonymous_index)
1120    {
1121        return Err(
1122            JwtTargetAcceptanceAuditError::ActiveLegPrerequisiteNotEstablished {
1123                role: invalid.role,
1124            },
1125        );
1126    }
1127    Ok(())
1128}
1129
1130fn active_prerequisite_is_established(
1131    legs: &[JwtTargetAcceptanceLegFact],
1132    valid_index: usize,
1133    anonymous_index: usize,
1134) -> bool {
1135    legs[valid_index].is_complete_classification()
1136        && legs[valid_index].marker_status == JwtTargetAcceptanceMarkerStatus::Observed
1137        && legs[anonymous_index].is_complete_classification()
1138        && legs[anonymous_index].marker_status == JwtTargetAcceptanceMarkerStatus::NotObserved
1139}
1140
1141fn validate_sequential_lifecycle(
1142    legs: &[JwtTargetAcceptanceLegFact],
1143) -> Result<(), JwtTargetAcceptanceAuditError> {
1144    let mut stopped = false;
1145    let mut retained_response_bytes = 0_u64;
1146    for (index, leg) in legs.iter().enumerate() {
1147        if stopped {
1148            if leg.was_dispatched() {
1149                return Err(JwtTargetAcceptanceAuditError::DispatchAfterStop { role: leg.role });
1150            }
1151            continue;
1152        }
1153
1154        if !leg.was_dispatched() {
1155            let prerequisite_skip = match index {
1156                2 => !active_prerequisite_is_established(legs, 0, 1),
1157                5 => !active_prerequisite_is_established(legs, 3, 4),
1158                _ => false,
1159            };
1160            if !prerequisite_skip {
1161                stopped = true;
1162            }
1163            continue;
1164        }
1165
1166        retained_response_bytes = retained_response_bytes
1167            .checked_add(leg.retained_response_bytes)
1168            .ok_or(JwtTargetAcceptanceAuditError::TotalResponseBytesExceeded)?;
1169        if !leg.was_committed()
1170            || retained_response_bytes >= MAX_JWT_TARGET_ACCEPTANCE_TOTAL_RESPONSE_BYTES
1171        {
1172            stopped = true;
1173        }
1174    }
1175    Ok(())
1176}
1177
1178fn pair_dimension(
1179    candidate: &JwtTargetAcceptanceLegFact,
1180    replay: &JwtTargetAcceptanceLegFact,
1181) -> JwtTargetAcceptanceDimension {
1182    if candidate.dispatch_status() == JwtTargetAcceptanceDispatchStatus::NotDispatched
1183        && replay.dispatch_status() == JwtTargetAcceptanceDispatchStatus::NotDispatched
1184    {
1185        return JwtTargetAcceptanceDimension::NotEvaluated;
1186    }
1187    if !candidate.is_complete_classification() || !replay.is_complete_classification() {
1188        return JwtTargetAcceptanceDimension::Incomplete;
1189    }
1190    match (candidate.marker_status, replay.marker_status) {
1191        (JwtTargetAcceptanceMarkerStatus::Observed, JwtTargetAcceptanceMarkerStatus::Observed) => {
1192            JwtTargetAcceptanceDimension::ObservedStable
1193        },
1194        (
1195            JwtTargetAcceptanceMarkerStatus::NotObserved,
1196            JwtTargetAcceptanceMarkerStatus::NotObserved,
1197        ) => JwtTargetAcceptanceDimension::NotObservedStable,
1198        _ => JwtTargetAcceptanceDimension::Unstable,
1199    }
1200}
1201
1202fn derive_conclusion(
1203    legs: &[JwtTargetAcceptanceLegFact],
1204    valid: JwtTargetAcceptanceDimension,
1205    anonymous: JwtTargetAcceptanceDimension,
1206    invalid: JwtTargetAcceptanceDimension,
1207) -> JwtTargetAcceptanceConclusion {
1208    if [valid, anonymous, invalid].contains(&JwtTargetAcceptanceDimension::Unstable) {
1209        return JwtTargetAcceptanceConclusion::UnstableInconclusive;
1210    }
1211    if anonymous == JwtTargetAcceptanceDimension::ObservedStable {
1212        return JwtTargetAcceptanceConclusion::PublicOrTokenAgnosticMarkerObserved;
1213    }
1214    if valid == JwtTargetAcceptanceDimension::ObservedStable
1215        && anonymous == JwtTargetAcceptanceDimension::NotObservedStable
1216        && invalid == JwtTargetAcceptanceDimension::ObservedStable
1217    {
1218        return JwtTargetAcceptanceConclusion::InvalidSignatureControlMarkerObservedWithAnonymousControl;
1219    }
1220    if valid == JwtTargetAcceptanceDimension::ObservedStable
1221        && anonymous == JwtTargetAcceptanceDimension::NotObservedStable
1222        && invalid == JwtTargetAcceptanceDimension::NotObservedStable
1223    {
1224        return JwtTargetAcceptanceConclusion::InvalidControlMarkerNotObserved;
1225    }
1226    if [valid, anonymous, invalid].iter().any(|dimension| {
1227        matches!(
1228            dimension,
1229            JwtTargetAcceptanceDimension::Incomplete | JwtTargetAcceptanceDimension::NotEvaluated
1230        )
1231    }) {
1232        return JwtTargetAcceptanceConclusion::Incomplete(first_incomplete_reason(legs));
1233    }
1234    JwtTargetAcceptanceConclusion::Inconclusive
1235}
1236
1237fn first_incomplete_reason(
1238    legs: &[JwtTargetAcceptanceLegFact],
1239) -> JwtTargetAcceptanceIncompleteReason {
1240    for fact in legs {
1241        if !fact.was_dispatched() {
1242            return JwtTargetAcceptanceIncompleteReason::LegNotDispatched { role: fact.role };
1243        }
1244        if !fact.was_committed() {
1245            return JwtTargetAcceptanceIncompleteReason::LegNotCommitted { role: fact.role };
1246        }
1247        match fact.response_status {
1248            JwtTargetAcceptanceResponseStatus::Incomplete => {
1249                return JwtTargetAcceptanceIncompleteReason::ResponseIncomplete { role: fact.role };
1250            },
1251            JwtTargetAcceptanceResponseStatus::NotClassified => {
1252                return JwtTargetAcceptanceIncompleteReason::ResponseNotClassified {
1253                    role: fact.role,
1254                };
1255            },
1256            JwtTargetAcceptanceResponseStatus::CompleteAndClassified => {},
1257        }
1258    }
1259    // A not-evaluated dimension can only arise from undispatched role slots,
1260    // which the loop above handles. This fallback keeps the function total if
1261    // a future enum variant is added without updating the derivation.
1262    JwtTargetAcceptanceIncompleteReason::LegNotDispatched {
1263        role: JwtTargetAcceptanceLegRole::ValidCandidate,
1264    }
1265}
1266
1267/// Static, redaction-safe aggregate validation failure.
1268#[derive(Debug, Clone, Copy, PartialEq, Eq, Error)]
1269#[non_exhaustive]
1270pub enum JwtTargetAcceptanceAuditError {
1271    /// The aggregate did not contain exactly six role slots.
1272    #[error("JWT target-acceptance audit requires exactly six ordered legs")]
1273    UnexpectedLegCount,
1274    /// One role did not appear at its exact zero-based ordinal.
1275    #[error("JWT target-acceptance leg role order is invalid")]
1276    UnexpectedRoleOrder { ordinal: u8 },
1277    /// A leg bypassed its own constructor invariants.
1278    #[error("JWT target-acceptance aggregate contains an invalid leg")]
1279    InvalidLeg {
1280        /// Role of the invalid fact.
1281        role: JwtTargetAcceptanceLegRole,
1282        /// Static invariant failure.
1283        reason: JwtTargetAcceptanceLegFactError,
1284    },
1285    /// An invalid-signature control was dispatched without its passive controls.
1286    #[error("JWT target-acceptance active leg prerequisite is not established")]
1287    ActiveLegPrerequisiteNotEstablished {
1288        /// Active role rejected by the prerequisite check.
1289        role: JwtTargetAcceptanceLegRole,
1290    },
1291    /// A later request was represented after cancellation, a failed commit,
1292    /// a passive undispatched slot, or exhaustion of the retained-byte cap had
1293    /// already frozen the ordered tail.
1294    #[error("JWT target-acceptance dispatched work appears after the ordered runtime stopped")]
1295    DispatchAfterStop {
1296        /// First dispatched role that appears after the stop boundary.
1297        role: JwtTargetAcceptanceLegRole,
1298    },
1299    /// More than six request dispatches were represented.
1300    #[error("JWT target-acceptance request count exceeds its limit")]
1301    RequestCountExceeded,
1302    /// More than two active request dispatches were represented.
1303    #[error("JWT target-acceptance active request count exceeds its limit")]
1304    ActiveRequestCountExceeded,
1305    /// Aggregate retained response bytes exceeded 256 KiB.
1306    #[error("JWT target-acceptance total response bytes exceed their limit")]
1307    TotalResponseBytesExceeded,
1308    /// Exact transport response-byte accounting overflowed `u64`.
1309    #[error("JWT target-acceptance transport response-byte accounting overflowed")]
1310    TransportResponseBytesOverflow,
1311    /// Two committed legs referenced the same evidence object.
1312    #[error("JWT target-acceptance evidence references must be unique")]
1313    DuplicateEvidenceReference,
1314}
1315
1316#[cfg(test)]
1317mod tests {
1318    use super::*;
1319
1320    const PRIVATE_FIELD: &str = "private_acceptance_marker";
1321
1322    fn policy() -> JwtTargetAcceptancePolicy {
1323        JwtTargetAcceptancePolicy::new(
1324            ("operator-policy", "revision-1"),
1325            Url::parse("https://example.test/app/").unwrap(),
1326            Url::parse("https://example.test/app/profile").unwrap(),
1327            "profile-resource",
1328            PRIVATE_FIELD,
1329        )
1330        .unwrap()
1331    }
1332
1333    fn complete(
1334        role: JwtTargetAcceptanceLegRole,
1335        marker: JwtTargetAcceptanceMarkerStatus,
1336        bytes: u64,
1337        evidence_ordinal: u8,
1338    ) -> JwtTargetAcceptanceLegFact {
1339        JwtTargetAcceptanceLegFact::new(
1340            role,
1341            JwtTargetAcceptanceDispatchStatus::Dispatched,
1342            JwtTargetAcceptanceCommitStatus::Committed,
1343            JwtTargetAcceptanceResponseStatus::CompleteAndClassified,
1344            marker,
1345            bytes,
1346            Some(format!("evidence-{evidence_ordinal:02}")),
1347        )
1348        .unwrap()
1349    }
1350
1351    fn complete_legs(
1352        valid_candidate: JwtTargetAcceptanceMarkerStatus,
1353        anonymous_candidate: JwtTargetAcceptanceMarkerStatus,
1354        invalid_candidate: JwtTargetAcceptanceMarkerStatus,
1355        valid_replay: JwtTargetAcceptanceMarkerStatus,
1356        anonymous_replay: JwtTargetAcceptanceMarkerStatus,
1357        invalid_replay: JwtTargetAcceptanceMarkerStatus,
1358    ) -> Vec<JwtTargetAcceptanceLegFact> {
1359        [
1360            valid_candidate,
1361            anonymous_candidate,
1362            invalid_candidate,
1363            valid_replay,
1364            anonymous_replay,
1365            invalid_replay,
1366        ]
1367        .into_iter()
1368        .enumerate()
1369        .map(|(index, marker)| {
1370            complete(
1371                JwtTargetAcceptanceLegRole::ORDERED[index],
1372                marker,
1373                1024,
1374                u8::try_from(index).unwrap(),
1375            )
1376        })
1377        .collect()
1378    }
1379
1380    #[test]
1381    fn positive_control_relationship_is_strict_and_value_free() {
1382        let audit = JwtTargetAcceptanceAudit::from_legs(
1383            &policy(),
1384            complete_legs(
1385                JwtTargetAcceptanceMarkerStatus::Observed,
1386                JwtTargetAcceptanceMarkerStatus::NotObserved,
1387                JwtTargetAcceptanceMarkerStatus::Observed,
1388                JwtTargetAcceptanceMarkerStatus::Observed,
1389                JwtTargetAcceptanceMarkerStatus::NotObserved,
1390                JwtTargetAcceptanceMarkerStatus::Observed,
1391            ),
1392        )
1393        .unwrap();
1394
1395        assert_eq!(audit.schema(), JWT_TARGET_ACCEPTANCE_AUDIT_SCHEMA);
1396        assert_eq!(audit.policy(), JWT_TARGET_ACCEPTANCE_POLICY_ID);
1397        assert_eq!(
1398            audit.valid_marker(),
1399            JwtTargetAcceptanceDimension::ObservedStable
1400        );
1401        assert_eq!(
1402            audit.anonymous_marker(),
1403            JwtTargetAcceptanceDimension::NotObservedStable
1404        );
1405        assert_eq!(
1406            audit.invalid_marker(),
1407            JwtTargetAcceptanceDimension::ObservedStable
1408        );
1409        assert_eq!(
1410            audit.conclusion(),
1411            JwtTargetAcceptanceConclusion::InvalidSignatureControlMarkerObservedWithAnonymousControl
1412        );
1413        assert_eq!(audit.accounting().dispatched_request_count(), 6);
1414        assert_eq!(audit.accounting().dispatched_passive_request_count(), 4);
1415        assert_eq!(audit.accounting().dispatched_active_request_count(), 2);
1416        assert_eq!(audit.accounting().committed_response_count(), 6);
1417
1418        let serialized = serde_json::to_string(&audit).unwrap();
1419        assert!(!serialized.contains("example.test"));
1420        assert!(!serialized.contains(PRIVATE_FIELD));
1421    }
1422
1423    #[test]
1424    fn stable_anonymous_marker_suppresses_invalid_control_work() {
1425        let roles = JwtTargetAcceptanceLegRole::ORDERED;
1426        let legs = vec![
1427            complete(roles[0], JwtTargetAcceptanceMarkerStatus::Observed, 10, 0),
1428            complete(roles[1], JwtTargetAcceptanceMarkerStatus::Observed, 10, 1),
1429            JwtTargetAcceptanceLegFact::not_dispatched(roles[2]),
1430            complete(roles[3], JwtTargetAcceptanceMarkerStatus::Observed, 10, 3),
1431            complete(roles[4], JwtTargetAcceptanceMarkerStatus::Observed, 10, 4),
1432            JwtTargetAcceptanceLegFact::not_dispatched(roles[5]),
1433        ];
1434        let audit = JwtTargetAcceptanceAudit::from_legs(&policy(), legs).unwrap();
1435
1436        assert_eq!(
1437            audit.anonymous_marker(),
1438            JwtTargetAcceptanceDimension::ObservedStable
1439        );
1440        assert_eq!(
1441            audit.invalid_marker(),
1442            JwtTargetAcceptanceDimension::NotEvaluated
1443        );
1444        assert_eq!(
1445            audit.conclusion(),
1446            JwtTargetAcceptanceConclusion::PublicOrTokenAgnosticMarkerObserved
1447        );
1448        assert_eq!(audit.accounting().dispatched_request_count(), 4);
1449        assert_eq!(audit.accounting().dispatched_active_request_count(), 0);
1450    }
1451
1452    #[test]
1453    fn stable_invalid_control_rejection_is_reported_narrowly() {
1454        let audit = JwtTargetAcceptanceAudit::from_legs(
1455            &policy(),
1456            complete_legs(
1457                JwtTargetAcceptanceMarkerStatus::Observed,
1458                JwtTargetAcceptanceMarkerStatus::NotObserved,
1459                JwtTargetAcceptanceMarkerStatus::NotObserved,
1460                JwtTargetAcceptanceMarkerStatus::Observed,
1461                JwtTargetAcceptanceMarkerStatus::NotObserved,
1462                JwtTargetAcceptanceMarkerStatus::NotObserved,
1463            ),
1464        )
1465        .unwrap();
1466        assert_eq!(
1467            audit.conclusion(),
1468            JwtTargetAcceptanceConclusion::InvalidControlMarkerNotObserved
1469        );
1470    }
1471
1472    #[test]
1473    fn replay_disagreement_is_unstable() {
1474        let roles = JwtTargetAcceptanceLegRole::ORDERED;
1475        let legs = vec![
1476            complete(roles[0], JwtTargetAcceptanceMarkerStatus::Observed, 10, 0),
1477            complete(
1478                roles[1],
1479                JwtTargetAcceptanceMarkerStatus::NotObserved,
1480                10,
1481                1,
1482            ),
1483            complete(roles[2], JwtTargetAcceptanceMarkerStatus::Observed, 10, 2),
1484            complete(
1485                roles[3],
1486                JwtTargetAcceptanceMarkerStatus::NotObserved,
1487                10,
1488                3,
1489            ),
1490            complete(
1491                roles[4],
1492                JwtTargetAcceptanceMarkerStatus::NotObserved,
1493                10,
1494                4,
1495            ),
1496            JwtTargetAcceptanceLegFact::not_dispatched(roles[5]),
1497        ];
1498        let audit = JwtTargetAcceptanceAudit::from_legs(&policy(), legs).unwrap();
1499        assert_eq!(audit.valid_marker(), JwtTargetAcceptanceDimension::Unstable);
1500        assert_eq!(
1501            audit.conclusion(),
1502            JwtTargetAcceptanceConclusion::UnstableInconclusive
1503        );
1504    }
1505
1506    #[test]
1507    fn incomplete_and_not_eligible_states_are_typed() {
1508        let roles = JwtTargetAcceptanceLegRole::ORDERED;
1509        let mut legs: Vec<_> = roles
1510            .into_iter()
1511            .map(JwtTargetAcceptanceLegFact::not_dispatched)
1512            .collect();
1513        legs[0] = JwtTargetAcceptanceLegFact::new(
1514            roles[0],
1515            JwtTargetAcceptanceDispatchStatus::Dispatched,
1516            JwtTargetAcceptanceCommitStatus::NotCommitted,
1517            JwtTargetAcceptanceResponseStatus::Incomplete,
1518            JwtTargetAcceptanceMarkerStatus::NotEvaluated,
1519            19,
1520            None,
1521        )
1522        .unwrap();
1523        let audit = JwtTargetAcceptanceAudit::from_legs(&policy(), legs).unwrap();
1524        assert_eq!(
1525            audit.conclusion(),
1526            JwtTargetAcceptanceConclusion::Incomplete(
1527                JwtTargetAcceptanceIncompleteReason::LegNotCommitted {
1528                    role: JwtTargetAcceptanceLegRole::ValidCandidate,
1529                }
1530            )
1531        );
1532
1533        let skipped = JwtTargetAcceptanceAudit::not_eligible(
1534            &policy(),
1535            JwtTargetAcceptanceNotEligibleReason::LocalSignatureNotEstablished,
1536        );
1537        assert!(skipped.legs().is_empty());
1538        assert_eq!(skipped.accounting().dispatched_request_count(), 0);
1539        assert_eq!(
1540            skipped.conclusion(),
1541            JwtTargetAcceptanceConclusion::Incomplete(
1542                JwtTargetAcceptanceIncompleteReason::NotEligible(
1543                    JwtTargetAcceptanceNotEligibleReason::LocalSignatureNotEstablished
1544                )
1545            )
1546        );
1547    }
1548
1549    #[test]
1550    fn policy_enforces_segment_boundaries_and_exact_origin() {
1551        let application = Url::parse("https://example.test:443/app/").unwrap();
1552        let accepted = [
1553            "https://example.test/app",
1554            "https://example.test/app/",
1555            "https://example.test/app/profile",
1556        ];
1557        for resource in accepted {
1558            JwtTargetAcceptancePolicy::new(
1559                ("operator-policy", "revision-1"),
1560                application.clone(),
1561                Url::parse(resource).unwrap(),
1562                "resource-1",
1563                "accepted",
1564            )
1565            .unwrap();
1566        }
1567
1568        for resource in [
1569            "https://example.test/application/profile",
1570            "https://example.test/app2/profile",
1571        ] {
1572            assert_eq!(
1573                JwtTargetAcceptancePolicy::new(
1574                    ("operator-policy", "revision-1"),
1575                    application.clone(),
1576                    Url::parse(resource).unwrap(),
1577                    "resource-1",
1578                    "accepted",
1579                )
1580                .unwrap_err(),
1581                JwtTargetAcceptancePolicyError::ResourceOutsideApplication
1582            );
1583        }
1584
1585        for resource in [
1586            "http://example.test/app/profile",
1587            "https://other.test/app/profile",
1588            "https://example.test:444/app/profile",
1589        ] {
1590            assert_eq!(
1591                JwtTargetAcceptancePolicy::new(
1592                    ("operator-policy", "revision-1"),
1593                    application.clone(),
1594                    Url::parse(resource).unwrap(),
1595                    "resource-1",
1596                    "accepted",
1597                )
1598                .unwrap_err(),
1599                JwtTargetAcceptancePolicyError::ForeignResourceOrigin
1600            );
1601        }
1602    }
1603
1604    #[test]
1605    fn policy_rejects_query_fragment_userinfo_and_unsafe_slugs() {
1606        let application = Url::parse("https://example.test/app/").unwrap();
1607        for resource in [
1608            "https://example.test/app/profile?view=1",
1609            "https://example.test/app/profile#marker",
1610            "https://user@example.test/app/profile",
1611        ] {
1612            assert_eq!(
1613                JwtTargetAcceptancePolicy::new(
1614                    ("operator-policy", "revision-1"),
1615                    application.clone(),
1616                    Url::parse(resource).unwrap(),
1617                    "resource-1",
1618                    "accepted",
1619                )
1620                .unwrap_err(),
1621                JwtTargetAcceptancePolicyError::InvalidResource
1622            );
1623        }
1624
1625        for identity in [("Uppercase", "revision-1"), ("operator", "bad revision")] {
1626            assert!(JwtTargetAcceptancePolicy::new(
1627                identity,
1628                application.clone(),
1629                Url::parse("https://example.test/app/profile").unwrap(),
1630                "resource-1",
1631                "accepted",
1632            )
1633            .is_err());
1634        }
1635        assert_eq!(
1636            JwtTargetAcceptancePolicy::new(
1637                ("operator", "revision-1"),
1638                application.clone(),
1639                Url::parse("https://example.test/app/profile").unwrap(),
1640                "bad resource",
1641                "accepted",
1642            )
1643            .unwrap_err(),
1644            JwtTargetAcceptancePolicyError::InvalidResourceReference
1645        );
1646        assert_eq!(
1647            JwtTargetAcceptancePolicy::new(
1648                ("operator", "revision-1"),
1649                application,
1650                Url::parse("https://example.test/app/profile").unwrap(),
1651                "resource-1",
1652                "bad field!",
1653            )
1654            .unwrap_err(),
1655            JwtTargetAcceptancePolicyError::InvalidSuccessJsonField
1656        );
1657    }
1658
1659    #[test]
1660    fn policy_accepts_http_without_minting_transport_authority() {
1661        let policy = JwtTargetAcceptancePolicy::new(
1662            ("operator", "revision-1"),
1663            Url::parse("http://127.0.0.1:8080/app/").unwrap(),
1664            Url::parse("http://127.0.0.1:8080/app/profile").unwrap(),
1665            "resource-1",
1666            "accepted",
1667        )
1668        .unwrap();
1669        assert_eq!(policy.method(), JwtTargetAcceptanceMethod::Get);
1670        assert_eq!(policy.application().scheme(), "http");
1671
1672        let rendered = format!("{policy:?}");
1673        assert!(!rendered.contains("127.0.0.1"));
1674        assert!(!rendered.contains("accepted"));
1675    }
1676
1677    #[test]
1678    fn role_contract_is_closed_and_ordered() {
1679        for (index, role) in JwtTargetAcceptanceLegRole::ORDERED.into_iter().enumerate() {
1680            assert_eq!(
1681                role.stage(),
1682                if index < 3 {
1683                    JwtTargetAcceptanceLegStage::Candidate
1684                } else {
1685                    JwtTargetAcceptanceLegStage::Replay
1686                }
1687            );
1688            assert_eq!(role.requires_authorization(), !role.is_anonymous());
1689            assert_eq!(role.is_active(), role.uses_invalid_token());
1690            assert_eq!(
1691                (
1692                    role.uses_valid_token(),
1693                    role.uses_invalid_token(),
1694                    role.is_anonymous(),
1695                ),
1696                match index % 3 {
1697                    0 => (true, false, false),
1698                    1 => (false, false, true),
1699                    _ => (false, true, false),
1700                }
1701            );
1702        }
1703        assert_eq!(
1704            JwtTargetAcceptanceLegRole::InvalidCandidate.action_id(),
1705            JWT_TARGET_ACCEPTANCE_INVALID_CONTROL_ACTION_ID
1706        );
1707    }
1708
1709    #[test]
1710    fn fact_constructor_rejects_inconsistent_states_and_oversized_bytes() {
1711        assert_eq!(
1712            JwtTargetAcceptanceLegFact::new(
1713                JwtTargetAcceptanceLegRole::ValidCandidate,
1714                JwtTargetAcceptanceDispatchStatus::NotDispatched,
1715                JwtTargetAcceptanceCommitStatus::Committed,
1716                JwtTargetAcceptanceResponseStatus::CompleteAndClassified,
1717                JwtTargetAcceptanceMarkerStatus::Observed,
1718                1,
1719                Some("evidence-1".to_owned()),
1720            )
1721            .unwrap_err(),
1722            JwtTargetAcceptanceLegFactError::InconsistentState
1723        );
1724        assert_eq!(
1725            JwtTargetAcceptanceLegFact::new(
1726                JwtTargetAcceptanceLegRole::ValidCandidate,
1727                JwtTargetAcceptanceDispatchStatus::Dispatched,
1728                JwtTargetAcceptanceCommitStatus::Committed,
1729                JwtTargetAcceptanceResponseStatus::CompleteAndClassified,
1730                JwtTargetAcceptanceMarkerStatus::NotEvaluated,
1731                1,
1732                Some("evidence-1".to_owned()),
1733            )
1734            .unwrap_err(),
1735            JwtTargetAcceptanceLegFactError::InconsistentState
1736        );
1737        assert_eq!(
1738            JwtTargetAcceptanceLegFact::new(
1739                JwtTargetAcceptanceLegRole::ValidCandidate,
1740                JwtTargetAcceptanceDispatchStatus::Dispatched,
1741                JwtTargetAcceptanceCommitStatus::NotCommitted,
1742                JwtTargetAcceptanceResponseStatus::Incomplete,
1743                JwtTargetAcceptanceMarkerStatus::NotEvaluated,
1744                MAX_JWT_TARGET_ACCEPTANCE_RESPONSE_BYTES + 1,
1745                None,
1746            )
1747            .unwrap_err(),
1748            JwtTargetAcceptanceLegFactError::ResponseBytesExceeded
1749        );
1750        assert_eq!(
1751            JwtTargetAcceptanceLegFact::new_with_transport_accounting(
1752                JwtTargetAcceptanceLegRole::ValidCandidate,
1753                JwtTargetAcceptanceDispatchStatus::Dispatched,
1754                JwtTargetAcceptanceCommitStatus::NotCommitted,
1755                JwtTargetAcceptanceResponseStatus::Incomplete,
1756                JwtTargetAcceptanceMarkerStatus::NotEvaluated,
1757                2,
1758                1,
1759                None,
1760            )
1761            .unwrap_err(),
1762            JwtTargetAcceptanceLegFactError::TransportBytesBelowRetained
1763        );
1764    }
1765
1766    #[test]
1767    fn aggregate_rejects_count_role_byte_and_evidence_mutations() {
1768        let markers = || {
1769            complete_legs(
1770                JwtTargetAcceptanceMarkerStatus::Observed,
1771                JwtTargetAcceptanceMarkerStatus::NotObserved,
1772                JwtTargetAcceptanceMarkerStatus::Observed,
1773                JwtTargetAcceptanceMarkerStatus::Observed,
1774                JwtTargetAcceptanceMarkerStatus::NotObserved,
1775                JwtTargetAcceptanceMarkerStatus::Observed,
1776            )
1777        };
1778
1779        let mut missing = markers();
1780        missing.pop();
1781        assert_eq!(
1782            JwtTargetAcceptanceAudit::from_legs(&policy(), missing).unwrap_err(),
1783            JwtTargetAcceptanceAuditError::UnexpectedLegCount
1784        );
1785
1786        let mut extra = markers();
1787        extra.push(JwtTargetAcceptanceLegFact::not_dispatched(
1788            JwtTargetAcceptanceLegRole::InvalidReplay,
1789        ));
1790        assert_eq!(
1791            JwtTargetAcceptanceAudit::from_legs(&policy(), extra).unwrap_err(),
1792            JwtTargetAcceptanceAuditError::UnexpectedLegCount
1793        );
1794
1795        let mut wrong_role = markers();
1796        wrong_role[1].role = JwtTargetAcceptanceLegRole::ValidCandidate;
1797        assert_eq!(
1798            JwtTargetAcceptanceAudit::from_legs(&policy(), wrong_role).unwrap_err(),
1799            JwtTargetAcceptanceAuditError::UnexpectedRoleOrder { ordinal: 1 }
1800        );
1801
1802        let mut too_many_bytes = markers();
1803        for fact in &mut too_many_bytes {
1804            fact.retained_response_bytes = 45 * 1024;
1805            fact.accounted_response_bytes = 45 * 1024;
1806        }
1807        assert_eq!(
1808            JwtTargetAcceptanceAudit::from_legs(&policy(), too_many_bytes).unwrap_err(),
1809            JwtTargetAcceptanceAuditError::TotalResponseBytesExceeded
1810        );
1811
1812        let mut transport_overrun = markers();
1813        transport_overrun[0].accounted_response_bytes =
1814            transport_overrun[0].retained_response_bytes + 8 * 1024;
1815        let transport_overrun =
1816            JwtTargetAcceptanceAudit::from_legs(&policy(), transport_overrun).unwrap();
1817        // `complete_legs` gives each of the six exact roles 1 KiB of
1818        // retained response data. The transport-only overrun below must not
1819        // change that independently specified retained total.
1820        assert_eq!(
1821            transport_overrun.accounting().retained_response_bytes(),
1822            6 * 1024
1823        );
1824        assert_eq!(
1825            transport_overrun.accounting().accounted_response_bytes(),
1826            (6 * 1024) + (8 * 1024)
1827        );
1828
1829        let mut duplicate_evidence = markers();
1830        duplicate_evidence[5].evidence_reference = duplicate_evidence[2].evidence_reference.clone();
1831        assert_eq!(
1832            JwtTargetAcceptanceAudit::from_legs(&policy(), duplicate_evidence).unwrap_err(),
1833            JwtTargetAcceptanceAuditError::DuplicateEvidenceReference
1834        );
1835    }
1836
1837    #[test]
1838    fn aggregate_revalidates_mutated_facts_and_active_prerequisites() {
1839        let roles = JwtTargetAcceptanceLegRole::ORDERED;
1840        let mut invalid_fact = JwtTargetAcceptanceLegFact::not_dispatched(roles[0]);
1841        invalid_fact.accounted_response_bytes = 1;
1842        let mut legs: Vec<_> = roles
1843            .into_iter()
1844            .map(JwtTargetAcceptanceLegFact::not_dispatched)
1845            .collect();
1846        legs[0] = invalid_fact;
1847        assert_eq!(
1848            JwtTargetAcceptanceAudit::from_legs(&policy(), legs).unwrap_err(),
1849            JwtTargetAcceptanceAuditError::InvalidLeg {
1850                role: JwtTargetAcceptanceLegRole::ValidCandidate,
1851                reason: JwtTargetAcceptanceLegFactError::InconsistentState,
1852            }
1853        );
1854
1855        let mut public = vec![
1856            complete(roles[0], JwtTargetAcceptanceMarkerStatus::Observed, 10, 0),
1857            complete(roles[1], JwtTargetAcceptanceMarkerStatus::Observed, 10, 1),
1858            complete(roles[2], JwtTargetAcceptanceMarkerStatus::Observed, 10, 2),
1859            complete(roles[3], JwtTargetAcceptanceMarkerStatus::Observed, 10, 3),
1860            complete(roles[4], JwtTargetAcceptanceMarkerStatus::Observed, 10, 4),
1861            JwtTargetAcceptanceLegFact::not_dispatched(roles[5]),
1862        ];
1863        assert_eq!(
1864            JwtTargetAcceptanceAudit::from_legs(&policy(), public.clone()).unwrap_err(),
1865            JwtTargetAcceptanceAuditError::ActiveLegPrerequisiteNotEstablished {
1866                role: JwtTargetAcceptanceLegRole::InvalidCandidate,
1867            }
1868        );
1869        public[2] = JwtTargetAcceptanceLegFact::not_dispatched(roles[2]);
1870        JwtTargetAcceptanceAudit::from_legs(&policy(), public).unwrap();
1871    }
1872
1873    #[test]
1874    fn aggregate_rejects_dispatch_after_ordered_stop_boundaries() {
1875        let roles = JwtTargetAcceptanceLegRole::ORDERED;
1876        let uncommitted = JwtTargetAcceptanceLegFact::new(
1877            roles[0],
1878            JwtTargetAcceptanceDispatchStatus::Dispatched,
1879            JwtTargetAcceptanceCommitStatus::NotCommitted,
1880            JwtTargetAcceptanceResponseStatus::Incomplete,
1881            JwtTargetAcceptanceMarkerStatus::NotEvaluated,
1882            1,
1883            None,
1884        )
1885        .unwrap();
1886        let mut after_uncommitted = roles
1887            .into_iter()
1888            .map(JwtTargetAcceptanceLegFact::not_dispatched)
1889            .collect::<Vec<_>>();
1890        after_uncommitted[0] = uncommitted;
1891        after_uncommitted[1] =
1892            complete(roles[1], JwtTargetAcceptanceMarkerStatus::NotObserved, 1, 1);
1893        assert_eq!(
1894            JwtTargetAcceptanceAudit::from_legs(&policy(), after_uncommitted).unwrap_err(),
1895            JwtTargetAcceptanceAuditError::DispatchAfterStop { role: roles[1] }
1896        );
1897
1898        let mut after_passive_skip = roles
1899            .into_iter()
1900            .map(JwtTargetAcceptanceLegFact::not_dispatched)
1901            .collect::<Vec<_>>();
1902        after_passive_skip[1] =
1903            complete(roles[1], JwtTargetAcceptanceMarkerStatus::NotObserved, 1, 1);
1904        assert_eq!(
1905            JwtTargetAcceptanceAudit::from_legs(&policy(), after_passive_skip).unwrap_err(),
1906            JwtTargetAcceptanceAuditError::DispatchAfterStop { role: roles[1] }
1907        );
1908
1909        let mut after_byte_limit = vec![
1910            complete(
1911                roles[0],
1912                JwtTargetAcceptanceMarkerStatus::Observed,
1913                MAX_JWT_TARGET_ACCEPTANCE_RESPONSE_BYTES,
1914                0,
1915            ),
1916            complete(
1917                roles[1],
1918                JwtTargetAcceptanceMarkerStatus::NotObserved,
1919                MAX_JWT_TARGET_ACCEPTANCE_RESPONSE_BYTES,
1920                1,
1921            ),
1922            complete(
1923                roles[2],
1924                JwtTargetAcceptanceMarkerStatus::Observed,
1925                MAX_JWT_TARGET_ACCEPTANCE_RESPONSE_BYTES,
1926                2,
1927            ),
1928            complete(
1929                roles[3],
1930                JwtTargetAcceptanceMarkerStatus::Observed,
1931                MAX_JWT_TARGET_ACCEPTANCE_RESPONSE_BYTES,
1932                3,
1933            ),
1934            complete(roles[4], JwtTargetAcceptanceMarkerStatus::NotObserved, 0, 4),
1935            JwtTargetAcceptanceLegFact::not_dispatched(roles[5]),
1936        ];
1937        assert_eq!(
1938            JwtTargetAcceptanceAudit::from_legs(&policy(), after_byte_limit.clone()).unwrap_err(),
1939            JwtTargetAcceptanceAuditError::DispatchAfterStop { role: roles[4] }
1940        );
1941
1942        after_byte_limit[4] = JwtTargetAcceptanceLegFact::not_dispatched(roles[4]);
1943        JwtTargetAcceptanceAudit::from_legs(&policy(), after_byte_limit)
1944            .expect("a frozen all-not-dispatched tail remains valid");
1945    }
1946
1947    #[test]
1948    fn evidence_reference_is_bounded_opaque_ascii_and_unique() {
1949        assert_eq!(
1950            JwtTargetAcceptanceLegFact::new(
1951                JwtTargetAcceptanceLegRole::ValidCandidate,
1952                JwtTargetAcceptanceDispatchStatus::Dispatched,
1953                JwtTargetAcceptanceCommitStatus::Committed,
1954                JwtTargetAcceptanceResponseStatus::CompleteAndClassified,
1955                JwtTargetAcceptanceMarkerStatus::Observed,
1956                1,
1957                Some("contains space".to_owned()),
1958            )
1959            .unwrap_err(),
1960            JwtTargetAcceptanceLegFactError::InvalidEvidenceReference
1961        );
1962        assert_eq!(
1963            JwtTargetAcceptanceLegFact::new(
1964                JwtTargetAcceptanceLegRole::ValidCandidate,
1965                JwtTargetAcceptanceDispatchStatus::Dispatched,
1966                JwtTargetAcceptanceCommitStatus::Committed,
1967                JwtTargetAcceptanceResponseStatus::CompleteAndClassified,
1968                JwtTargetAcceptanceMarkerStatus::Observed,
1969                1,
1970                Some("x".repeat(MAX_JWT_TARGET_ACCEPTANCE_EVIDENCE_REFERENCE_BYTES + 1)),
1971            )
1972            .unwrap_err(),
1973            JwtTargetAcceptanceLegFactError::InvalidEvidenceReference
1974        );
1975    }
1976}