Skip to main content

termivar_scanner/web_runtime/
assessment_item.rs

1//! Claim-safe, read-only product projection for deterministic assessment truth.
2//!
3//! This module owns no detector, transport, verifier, renderer, or persistence
4//! authority. It can only reduce already committed runtime truth into bounded,
5//! opaque references. In particular, action success alone is never sufficient
6//! for [`AssessmentDisposition::Confirmed`].
7
8use std::collections::{BTreeMap, BTreeSet};
9use std::fmt;
10
11use sha2::{Digest, Sha256};
12use termivar_core::{
13    ApiEvidencePredicate, ApiVisibilityDimension, EntityId, EvidenceId, EvidenceValue, Probability,
14    SecuritySeverity, VerificationStage,
15};
16#[cfg(test)]
17use termivar_core::{HypothesisState, KnowledgePredicate, Outcome, OutcomeStatus};
18use thiserror::Error;
19use url::Url;
20
21use crate::api_observation::api_visibility_review_for_commit;
22use crate::knowledge::KnowledgeAuthority;
23use crate::KnowledgeBase;
24use crate::{ApiObservationCommitReceipt, ApiVisibilityReview, ApiVisibilityReviewDisposition};
25#[cfg(test)]
26use crate::{DecisionEvidenceReceipt, DecisionExecutionStage, DecisionOutcomeReport};
27
28/// Stable schema carried by every assessment item.
29pub const ASSESSMENT_ITEM_SCHEMA: &str = "venom-assessment-item/v1";
30/// Maximum retained evidence references for one assessment item.
31pub const MAX_ASSESSMENT_ITEM_EVIDENCE_REFERENCES: usize = 256;
32/// Maximum number of items one projection authority can retain.
33pub const MAX_ASSESSMENT_ITEM_SET_ITEMS: usize = 4_096;
34/// Maximum bytes in one static capability identifier.
35pub const MAX_ASSESSMENT_CAPABILITY_ID_BYTES: usize = 128;
36/// Maximum bytes in one static display field.
37pub const MAX_ASSESSMENT_DISPLAY_BYTES: usize = 1_024;
38
39const FINGERPRINT_DOMAIN: &[u8] = b"venom.assessment-item.fingerprint.v1\0";
40const SCOPE_IDENTITY_DOMAIN: &[u8] = b"venom.assessment-scope.exact-origin.v1\0";
41const SUBJECT_IDENTITY_DOMAIN: &[u8] = b"venom.assessment-subject.identity.v1\0";
42const DISCOVERED_SUBJECT_IDENTITY_DOMAIN: &[u8] =
43    b"venom.assessment-subject.discovered-resource.v1\0";
44const DISCOVERED_SUBJECT_IDENTITY_VERSION: &str = "discovered-resource@1";
45#[cfg(feature = "graphql-review")]
46const GRAPHQL_SUBJECT_IDENTITY_DOMAIN: &[u8] = b"venom.assessment-subject.anonymous-graphql.v1\0";
47#[cfg(feature = "graphql-review")]
48const GRAPHQL_SUBJECT_IDENTITY_VERSION: &str = "anonymous-graphql-endpoint@1";
49const MAX_STABLE_SUBJECT_ID_BYTES: usize = 256;
50const MAX_QUERY_PARAMETER_NAME_BYTES: usize = 256;
51const MAX_PROJECTION_SUBJECTS: usize = 1_024;
52const MAX_PROJECTION_QUERY_NAMES_PER_SUBJECT: usize = 256;
53#[cfg(test)]
54const MAX_PROJECTION_CASES: usize = 10_000;
55#[cfg(test)]
56const MAX_PROJECTION_OUTCOMES: usize = 10_000;
57const MAX_PROJECTION_EVIDENCE: usize = 262_144;
58const MAX_PROJECTION_SUBJECT_ID_BYTES: usize = 16_384;
59const MAX_PROJECTION_RUNTIME_ID_BYTES: usize = 1_024;
60
61/// Host-approved, stable, non-secret assessment scope identity. The built-in
62/// host may derive this from the already-public exact origin, but never from a
63/// credential-bearing URL component.
64#[derive(Clone, PartialEq, Eq)]
65pub(crate) struct StableAssessmentScopeId(String);
66
67impl StableAssessmentScopeId {
68    pub(crate) fn from_exact_origin(value: &str) -> Result<Self, AssessmentItemProjectionError> {
69        let url = Url::parse(value)
70            .map_err(|_| AssessmentItemProjectionError::InvalidStableScopeIdentity)?;
71        if !matches!(url.scheme(), "http" | "https")
72            || !url.username().is_empty()
73            || url.password().is_some()
74            || url.host().is_none()
75            || url.path() != "/"
76            || url.query().is_some()
77            || url.fragment().is_some()
78            || url.origin().ascii_serialization() != value
79        {
80            return Err(AssessmentItemProjectionError::InvalidStableScopeIdentity);
81        }
82        let mut digest = Sha256::new();
83        digest.update(SCOPE_IDENTITY_DOMAIN);
84        digest_field(&mut digest, value);
85        Ok(Self(format!("origin-sha256:{:x}", digest.finalize())))
86    }
87
88    fn as_str(&self) -> &str {
89        &self.0
90    }
91
92    #[cfg(feature = "reporting")]
93    fn matches_exact_origin(&self, value: &str) -> bool {
94        Self::from_exact_origin(value).is_ok_and(|expected| expected == *self)
95    }
96}
97
98impl fmt::Debug for StableAssessmentScopeId {
99    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
100        formatter.write_str("StableAssessmentScopeId(<host-approved>)")
101    }
102}
103
104/// Product-facing claim disposition.
105///
106/// This vocabulary is intentionally distinct from verifier outcome status.
107/// `Success` is an action result; `Confirmed` is a separately authorized
108/// security-claim projection.
109#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
110#[non_exhaustive]
111pub enum AssessmentDisposition {
112    /// A bounded observation with no vulnerability claim.
113    Informational,
114    /// A typed relationship warrants authorized human review.
115    NeedsReview,
116    /// A verifier-authorized vulnerability hypothesis transition was committed.
117    Confirmed,
118}
119
120impl AssessmentDisposition {
121    /// Returns the stable product token.
122    pub const fn as_str(self) -> &'static str {
123        match self {
124            Self::Informational => "informational",
125            Self::NeedsReview => "needs_review",
126            Self::Confirmed => "confirmed",
127        }
128    }
129}
130
131/// Opaque deterministic reference to one canonical assessment subject.
132#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
133pub struct AssessmentSubjectReference(u32);
134
135impl AssessmentSubjectReference {
136    pub(crate) const fn new(ordinal: u32) -> Self {
137        Self(ordinal)
138    }
139
140    /// Returns the deterministic document-local ordinal.
141    pub const fn ordinal(self) -> u32 {
142        self.0
143    }
144}
145
146impl fmt::Display for AssessmentSubjectReference {
147    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
148        write!(formatter, "subject-{:04}", self.0)
149    }
150}
151
152/// Opaque deterministic reference to one retained evidence record.
153#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
154pub struct AssessmentEvidenceReference(u32);
155
156impl AssessmentEvidenceReference {
157    pub(crate) const fn new(ordinal: u32) -> Self {
158        Self(ordinal)
159    }
160
161    /// Returns the deterministic document-local ordinal.
162    pub const fn ordinal(self) -> u32 {
163        self.0
164    }
165}
166
167impl fmt::Display for AssessmentEvidenceReference {
168    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
169        write!(formatter, "evidence-{:04}", self.0)
170    }
171}
172
173/// Opaque deterministic reference to one verification case.
174#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
175pub struct AssessmentCaseReference(u32);
176
177impl AssessmentCaseReference {
178    #[cfg(test)]
179    pub(crate) const fn new(ordinal: u32) -> Self {
180        Self(ordinal)
181    }
182
183    /// Returns the deterministic document-local ordinal.
184    pub const fn ordinal(self) -> u32 {
185        self.0
186    }
187}
188
189impl fmt::Display for AssessmentCaseReference {
190    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
191        write!(formatter, "case-{:04}", self.0)
192    }
193}
194
195/// Opaque deterministic reference to one verifier outcome.
196#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
197pub struct AssessmentOutcomeReference(u32);
198
199impl AssessmentOutcomeReference {
200    #[cfg(test)]
201    pub(crate) const fn new(ordinal: u32) -> Self {
202        Self(ordinal)
203    }
204
205    /// Returns the deterministic document-local ordinal.
206    pub const fn ordinal(self) -> u32 {
207        self.0
208    }
209}
210
211impl fmt::Display for AssessmentOutcomeReference {
212    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
213        write!(formatter, "outcome-{:04}", self.0)
214    }
215}
216
217/// Host-approved, stable, non-secret subject identity used only for product
218/// identity. This is not a redaction primitive: callers must never construct it
219/// from a credential, raw URL, URL path, query value, cookie value, or other
220/// secret and then assume the digest makes that input confidential.
221#[derive(Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
222pub(crate) struct StableAssessmentSubjectId(String);
223
224impl StableAssessmentSubjectId {
225    pub(crate) fn new(value: impl Into<String>) -> Result<Self, AssessmentItemProjectionError> {
226        let value = value.into();
227        if !valid_stable_product_identity(&value) {
228            return Err(AssessmentItemProjectionError::InvalidStableSubjectIdentity);
229        }
230        Ok(Self(value))
231    }
232
233    pub(crate) fn from_discovered_resource(
234        stable_scope_id: &StableAssessmentScopeId,
235        method: super::web_assessment::WebAssessmentMethod,
236        canonical_url: &Url,
237        query_parameter_names: &[String],
238    ) -> Result<Self, AssessmentItemProjectionError> {
239        let exact_origin = canonical_url.origin().ascii_serialization();
240        if !StableAssessmentScopeId::from_exact_origin(&exact_origin)
241            .is_ok_and(|expected| expected == *stable_scope_id)
242            || !matches!(canonical_url.scheme(), "http" | "https")
243            || canonical_url.host().is_none()
244            || !canonical_url.username().is_empty()
245            || canonical_url.password().is_some()
246            || canonical_url.path().is_empty()
247            || canonical_url.query().is_some()
248            || canonical_url.fragment().is_some()
249            || canonical_url.as_str().len() > MAX_PROJECTION_SUBJECT_ID_BYTES
250            || query_parameter_names.len() > MAX_PROJECTION_QUERY_NAMES_PER_SUBJECT
251        {
252            return Err(AssessmentItemProjectionError::InvalidStableSubjectIdentity);
253        }
254
255        let mut canonical_names = BTreeSet::new();
256        for name in query_parameter_names {
257            if name.is_empty()
258                || name.len() > MAX_QUERY_PARAMETER_NAME_BYTES
259                || name.chars().any(char::is_control)
260            {
261                return Err(AssessmentItemProjectionError::InvalidStableSubjectIdentity);
262            }
263            canonical_names.insert(name.as_str());
264        }
265
266        let method = match method {
267            super::web_assessment::WebAssessmentMethod::Get => "GET",
268            super::web_assessment::WebAssessmentMethod::Head => "HEAD",
269        };
270        let mut digest = Sha256::new();
271        digest.update(DISCOVERED_SUBJECT_IDENTITY_DOMAIN);
272        digest_field(&mut digest, DISCOVERED_SUBJECT_IDENTITY_VERSION);
273        digest_field(&mut digest, stable_scope_id.as_str());
274        digest_field(&mut digest, method);
275        digest_field(&mut digest, canonical_url.path());
276        digest.update((canonical_names.len() as u64).to_be_bytes());
277        for name in canonical_names {
278            digest_field(&mut digest, name);
279        }
280        Self::new(format!(
281            "{DISCOVERED_SUBJECT_IDENTITY_VERSION}:{:x}",
282            digest.finalize()
283        ))
284    }
285
286    /// Derives the stable identity for one anonymous POST-only GraphQL endpoint.
287    ///
288    /// The identity binds only non-secret structural values: the exact-origin
289    /// scope, canonical path, protocol, strategy revision, and anonymous
290    /// context. Operation aliases, bodies, response values, and schema names
291    /// never enter this identity.
292    #[cfg(feature = "graphql-review")]
293    pub(crate) fn from_anonymous_graphql_endpoint(
294        stable_scope_id: &StableAssessmentScopeId,
295        canonical_url: &Url,
296    ) -> Result<Self, AssessmentItemProjectionError> {
297        let exact_origin = canonical_url.origin().ascii_serialization();
298        if !StableAssessmentScopeId::from_exact_origin(&exact_origin)
299            .is_ok_and(|expected| expected == *stable_scope_id)
300            || !matches!(canonical_url.scheme(), "http" | "https")
301            || canonical_url.host().is_none()
302            || !canonical_url.username().is_empty()
303            || canonical_url.password().is_some()
304            || canonical_url.path().is_empty()
305            || canonical_url.query().is_some()
306            || canonical_url.fragment().is_some()
307            || canonical_url.as_str().len() > MAX_PROJECTION_SUBJECT_ID_BYTES
308        {
309            return Err(AssessmentItemProjectionError::InvalidStableSubjectIdentity);
310        }
311
312        let mut digest = Sha256::new();
313        digest.update(GRAPHQL_SUBJECT_IDENTITY_DOMAIN);
314        digest_field(&mut digest, GRAPHQL_SUBJECT_IDENTITY_VERSION);
315        digest_field(&mut digest, stable_scope_id.as_str());
316        digest_field(&mut digest, "POST");
317        digest_field(&mut digest, canonical_url.path());
318        digest_field(&mut digest, "graphql");
319        digest_field(
320            &mut digest,
321            crate::graphql_review::GRAPHQL_REVIEW_STRATEGY_ID,
322        );
323        digest_field(&mut digest, "anonymous");
324        Self::new(format!(
325            "{GRAPHQL_SUBJECT_IDENTITY_VERSION}:{:x}",
326            digest.finalize()
327        ))
328    }
329
330    fn as_str(&self) -> &str {
331        &self.0
332    }
333}
334
335fn valid_stable_product_identity(value: &str) -> bool {
336    !value.is_empty()
337        && value.len() <= MAX_STABLE_SUBJECT_ID_BYTES
338        && value.trim() == value
339        && value.bytes().all(|byte| {
340            byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'_' | b':' | b'@' | b'-')
341        })
342}
343
344impl fmt::Debug for StableAssessmentSubjectId {
345    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
346        formatter.write_str("StableAssessmentSubjectId(<host-approved>)")
347    }
348}
349
350/// Closed, non-secret discriminator for one item within a canonical subject.
351#[derive(Clone, PartialEq, Eq)]
352pub(crate) enum AssessmentItemTarget {
353    Subject,
354    QueryParameter(String),
355    #[cfg(feature = "ssrf-oast-review")]
356    SsrfOastQuery(String),
357    #[cfg(feature = "authorization-review")]
358    AuthorizationResource(String),
359    #[cfg(feature = "openapi-review")]
360    OpenApiDocument(String),
361    #[cfg(feature = "rest-review")]
362    RestOperation(String),
363}
364
365/// Value-free presentation discriminator derived from an assessment target.
366///
367/// This deliberately retains only the closed target variant. In particular,
368/// query names and stable product identities never enter presentation state.
369#[derive(Debug, Clone, Copy, PartialEq, Eq)]
370enum AssessmentItemPresentationTarget {
371    AssessmentSubject,
372    QueryParameter,
373    #[cfg(feature = "ssrf-oast-review")]
374    SsrfOastQuery,
375    #[cfg(feature = "authorization-review")]
376    AuthorizationResource,
377    #[cfg(feature = "openapi-review")]
378    OpenApiDocument,
379    #[cfg(feature = "rest-review")]
380    RestOperation,
381}
382
383impl AssessmentItemPresentationTarget {
384    const fn as_str(self) -> &'static str {
385        match self {
386            Self::AssessmentSubject => "assessment_subject",
387            Self::QueryParameter => "query_parameter",
388            #[cfg(feature = "ssrf-oast-review")]
389            Self::SsrfOastQuery => "ssrf_oast_query",
390            #[cfg(feature = "authorization-review")]
391            Self::AuthorizationResource => "authorization_resource",
392            #[cfg(feature = "openapi-review")]
393            Self::OpenApiDocument => "openapi_document",
394            #[cfg(feature = "rest-review")]
395            Self::RestOperation => "rest_operation",
396        }
397    }
398}
399
400impl AssessmentItemTarget {
401    pub(crate) const fn subject() -> Self {
402        Self::Subject
403    }
404
405    const fn presentation_target(&self) -> AssessmentItemPresentationTarget {
406        match self {
407            Self::Subject => AssessmentItemPresentationTarget::AssessmentSubject,
408            Self::QueryParameter(_) => AssessmentItemPresentationTarget::QueryParameter,
409            #[cfg(feature = "ssrf-oast-review")]
410            Self::SsrfOastQuery(_) => AssessmentItemPresentationTarget::SsrfOastQuery,
411            #[cfg(feature = "authorization-review")]
412            Self::AuthorizationResource(_) => {
413                AssessmentItemPresentationTarget::AuthorizationResource
414            },
415            #[cfg(feature = "openapi-review")]
416            Self::OpenApiDocument(_) => AssessmentItemPresentationTarget::OpenApiDocument,
417            #[cfg(feature = "rest-review")]
418            Self::RestOperation(_) => AssessmentItemPresentationTarget::RestOperation,
419        }
420    }
421
422    pub(crate) fn query_parameter(
423        name: impl Into<String>,
424    ) -> Result<Self, AssessmentItemProjectionError> {
425        let name = name.into();
426        if name.is_empty()
427            || name.len() > MAX_QUERY_PARAMETER_NAME_BYTES
428            || name.chars().any(char::is_control)
429        {
430            return Err(AssessmentItemProjectionError::InvalidQueryParameterTarget);
431        }
432        Ok(Self::QueryParameter(name))
433    }
434
435    #[cfg(feature = "ssrf-oast-review")]
436    pub(crate) fn ssrf_oast_query(
437        identity: impl Into<String>,
438    ) -> Result<Self, AssessmentItemProjectionError> {
439        let identity = identity.into();
440        let Some(digest) = identity.strip_prefix("ssrf-oast-parameter-sha256:") else {
441            return Err(AssessmentItemProjectionError::InvalidStableSubjectIdentity);
442        };
443        if digest.len() != 64
444            || !digest
445                .bytes()
446                .all(|byte| byte.is_ascii_digit() || matches!(byte, b'a'..=b'f'))
447        {
448            return Err(AssessmentItemProjectionError::InvalidStableSubjectIdentity);
449        }
450        Ok(Self::SsrfOastQuery(identity))
451    }
452
453    #[cfg(feature = "authorization-review")]
454    pub(crate) fn authorization_resource(
455        identity: impl Into<String>,
456    ) -> Result<Self, AssessmentItemProjectionError> {
457        let identity = identity.into();
458        if !valid_stable_product_identity(&identity) {
459            return Err(AssessmentItemProjectionError::InvalidStableSubjectIdentity);
460        }
461        Ok(Self::AuthorizationResource(identity))
462    }
463
464    #[cfg(feature = "openapi-review")]
465    pub(crate) fn openapi_document(
466        identity: impl Into<String>,
467    ) -> Result<Self, AssessmentItemProjectionError> {
468        let identity = identity.into();
469        if !valid_stable_product_identity(&identity) {
470            return Err(AssessmentItemProjectionError::InvalidStableSubjectIdentity);
471        }
472        Ok(Self::OpenApiDocument(identity))
473    }
474
475    #[cfg(feature = "rest-review")]
476    pub(crate) fn rest_operation(
477        identity: impl Into<String>,
478    ) -> Result<Self, AssessmentItemProjectionError> {
479        let identity = identity.into();
480        if !valid_stable_product_identity(&identity) {
481            return Err(AssessmentItemProjectionError::InvalidStableSubjectIdentity);
482        }
483        Ok(Self::RestOperation(identity))
484    }
485}
486
487impl fmt::Debug for AssessmentItemTarget {
488    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
489        match self {
490            Self::Subject => formatter.write_str("AssessmentItemTarget::Subject"),
491            Self::QueryParameter(_) => {
492                formatter.write_str("AssessmentItemTarget::QueryParameter(<name-only>)")
493            },
494            #[cfg(feature = "ssrf-oast-review")]
495            Self::SsrfOastQuery(_) => {
496                formatter.write_str("AssessmentItemTarget::SsrfOastQuery(<stable-digest>)")
497            },
498            #[cfg(feature = "authorization-review")]
499            Self::AuthorizationResource(_) => {
500                formatter.write_str("AssessmentItemTarget::AuthorizationResource(<stable-digest>)")
501            },
502            #[cfg(feature = "openapi-review")]
503            Self::OpenApiDocument(_) => {
504                formatter.write_str("AssessmentItemTarget::OpenApiDocument(<stable-digest>)")
505            },
506            #[cfg(feature = "rest-review")]
507            Self::RestOperation(_) => {
508                formatter.write_str("AssessmentItemTarget::RestOperation(<stable-digest>)")
509            },
510        }
511    }
512}
513
514#[cfg(test)]
515#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
516struct RuntimeOutcomeIdentity {
517    subject: EntityId,
518    case_id: String,
519    action_id: String,
520    hypothesis_id: String,
521    verifier_rule_id: Option<String>,
522    stage: &'static str,
523    status: OutcomeStatus,
524    confidence: Probability,
525    evidence_ids: BTreeSet<EvidenceId>,
526}
527
528#[cfg(test)]
529impl RuntimeOutcomeIdentity {
530    fn from_outcome(outcome: &Outcome) -> Self {
531        Self {
532            subject: outcome.subject().clone(),
533            case_id: outcome.case_id().to_owned(),
534            action_id: outcome.action_id().to_owned(),
535            hypothesis_id: outcome.hypothesis_id().to_owned(),
536            verifier_rule_id: outcome.verifier_rule_id().map(str::to_owned),
537            stage: outcome.stage().as_str(),
538            status: outcome.status(),
539            confidence: outcome.confidence(),
540            evidence_ids: outcome.evidence_ids().clone(),
541        }
542    }
543}
544
545#[derive(Clone, PartialEq, Eq)]
546struct SubjectProjection {
547    reference: AssessmentSubjectReference,
548    stable_id: StableAssessmentSubjectId,
549    query_parameter_names: BTreeSet<String>,
550}
551
552#[derive(Clone, PartialEq, Eq)]
553struct EvidenceProjection {
554    reference: AssessmentEvidenceReference,
555    subject: EntityId,
556}
557
558/// One context-owned collection of claim-safe assessment items.
559///
560/// The private item vector has no append or merge surface. A set can only be
561/// produced by consuming the projection context that minted every contained
562/// document-local reference, so references from independent contexts cannot
563/// be combined into one report.
564pub(crate) struct AssessmentItemSet {
565    #[cfg(feature = "reporting")]
566    stable_scope_id: StableAssessmentScopeId,
567    #[cfg(feature = "reporting")]
568    evidence_references: BTreeMap<EvidenceId, AssessmentEvidenceReference>,
569    subjects: Vec<AssessmentSubjectInventoryEntry>,
570    items: Vec<AssessmentItem>,
571}
572
573#[derive(Clone, PartialEq, Eq)]
574pub(crate) struct AssessmentSubjectInventoryEntry {
575    reference: AssessmentSubjectReference,
576    fingerprint: String,
577}
578
579impl AssessmentSubjectInventoryEntry {
580    pub(crate) const fn reference(&self) -> AssessmentSubjectReference {
581        self.reference
582    }
583
584    #[cfg(any(feature = "reporting", test))]
585    pub(crate) fn fingerprint(&self) -> &str {
586        &self.fingerprint
587    }
588}
589
590impl fmt::Debug for AssessmentSubjectInventoryEntry {
591    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
592        formatter
593            .debug_struct("AssessmentSubjectInventoryEntry")
594            .field("reference", &self.reference)
595            .field("fingerprint", &"<stable-digest>")
596            .finish()
597    }
598}
599
600impl AssessmentItemSet {
601    pub(crate) fn items(&self) -> &[AssessmentItem] {
602        &self.items
603    }
604
605    #[cfg(feature = "reporting")]
606    pub(crate) fn matches_exact_origin(&self, value: &str) -> bool {
607        self.stable_scope_id.matches_exact_origin(value)
608    }
609
610    /// Verifies that this set contains a subject minted from the supplied
611    /// host-approved stable identity under its existing scope.
612    ///
613    /// Report composition uses this to bind the opaque inventory back to the
614    /// runtime's authorized root without exposing or re-hashing a URL path.
615    #[cfg(feature = "reporting")]
616    pub(crate) fn contains_stable_subject(&self, stable_identity: &str) -> bool {
617        let Ok(stable_identity) = StableAssessmentSubjectId::new(stable_identity) else {
618            return false;
619        };
620        let expected = assessment_subject_fingerprint(&self.stable_scope_id, &stable_identity);
621        self.subjects.iter().any(|subject| {
622            subject.reference() == AssessmentSubjectReference::new(0)
623                && subject.fingerprint() == expected
624        })
625    }
626
627    #[cfg(test)]
628    pub(crate) fn into_parts(self) -> (Vec<AssessmentSubjectInventoryEntry>, Vec<AssessmentItem>) {
629        (self.subjects, self.items)
630    }
631
632    #[cfg(feature = "reporting")]
633    pub(crate) fn into_report_parts(
634        self,
635    ) -> (
636        Vec<AssessmentSubjectInventoryEntry>,
637        Vec<AssessmentItem>,
638        BTreeMap<EvidenceId, AssessmentEvidenceReference>,
639    ) {
640        (self.subjects, self.items, self.evidence_references)
641    }
642}
643
644impl fmt::Debug for AssessmentItemSet {
645    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
646        formatter
647            .debug_struct("AssessmentItemSet")
648            .field("subject_count", &self.subjects.len())
649            .field("item_count", &self.items.len())
650            .finish()
651    }
652}
653
654/// Crate-owned mapping authority from exact runtime identities to opaque
655/// document-local references. Callers never select references directly.
656pub(crate) struct AssessmentProjectionContext {
657    knowledge_authority: KnowledgeAuthority,
658    stable_scope_id: StableAssessmentScopeId,
659    subjects: BTreeMap<EntityId, SubjectProjection>,
660    stable_subject_ids: BTreeSet<StableAssessmentSubjectId>,
661    #[cfg(test)]
662    cases: BTreeMap<(EntityId, String), AssessmentCaseReference>,
663    #[cfg(test)]
664    outcomes: BTreeMap<RuntimeOutcomeIdentity, AssessmentOutcomeReference>,
665    evidence: BTreeMap<EvidenceId, EvidenceProjection>,
666    items: Vec<AssessmentItem>,
667}
668
669impl fmt::Debug for AssessmentProjectionContext {
670    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
671        let mut debug = formatter.debug_struct("AssessmentProjectionContext");
672        debug.field("subject_count", &self.subjects.len());
673        #[cfg(test)]
674        debug
675            .field("case_count", &self.cases.len())
676            .field("outcome_count", &self.outcomes.len());
677        debug
678            .field("evidence_count", &self.evidence.len())
679            .field("item_count", &self.items.len())
680            .finish()
681    }
682}
683
684impl AssessmentProjectionContext {
685    pub(crate) fn new(knowledge: &KnowledgeBase, stable_scope_id: StableAssessmentScopeId) -> Self {
686        Self {
687            knowledge_authority: knowledge.authority().clone(),
688            stable_scope_id,
689            subjects: BTreeMap::new(),
690            stable_subject_ids: BTreeSet::new(),
691            #[cfg(test)]
692            cases: BTreeMap::new(),
693            #[cfg(test)]
694            outcomes: BTreeMap::new(),
695            evidence: BTreeMap::new(),
696            items: Vec::new(),
697        }
698    }
699
700    pub(crate) fn register_subject(
701        &mut self,
702        subject: EntityId,
703        stable_id: StableAssessmentSubjectId,
704        query_parameter_names: impl IntoIterator<Item = String>,
705    ) -> Result<AssessmentSubjectReference, AssessmentItemProjectionError> {
706        check_projection_limit("subjects", self.subjects.len(), MAX_PROJECTION_SUBJECTS)?;
707        validate_runtime_identity(subject.as_str(), MAX_PROJECTION_SUBJECT_ID_BYTES)?;
708        if self.subjects.contains_key(&subject) {
709            return Err(AssessmentItemProjectionError::DuplicateSubjectMapping);
710        }
711        if self.stable_subject_ids.contains(&stable_id) {
712            return Err(AssessmentItemProjectionError::DuplicateStableSubjectIdentity);
713        }
714        let mut names = BTreeSet::new();
715        for name in query_parameter_names {
716            check_projection_limit(
717                "query_parameter_names",
718                names.len(),
719                MAX_PROJECTION_QUERY_NAMES_PER_SUBJECT,
720            )?;
721            let AssessmentItemTarget::QueryParameter(name) =
722                AssessmentItemTarget::query_parameter(name)?
723            else {
724                unreachable!("query-parameter constructor returned another target")
725            };
726            if !names.insert(name) {
727                return Err(AssessmentItemProjectionError::DuplicateQueryParameterTarget);
728            }
729        }
730        let reference =
731            AssessmentSubjectReference::new(next_ordinal(self.subjects.len(), "subject")?);
732        self.stable_subject_ids.insert(stable_id.clone());
733        self.subjects.insert(
734            subject,
735            SubjectProjection {
736                reference,
737                stable_id,
738                query_parameter_names: names,
739            },
740        );
741        Ok(reference)
742    }
743
744    #[cfg(any(feature = "ssrf-oast-review", feature = "xml-external-entity-review"))]
745    pub(super) fn has_subject(&self, subject: &EntityId) -> bool {
746        self.subjects.contains_key(subject)
747    }
748
749    #[cfg(test)]
750    pub(crate) fn register_case(
751        &mut self,
752        subject: &EntityId,
753        case_id: impl Into<String>,
754    ) -> Result<AssessmentCaseReference, AssessmentItemProjectionError> {
755        check_projection_limit("cases", self.cases.len(), MAX_PROJECTION_CASES)?;
756        if !self.subjects.contains_key(subject) {
757            return Err(AssessmentItemProjectionError::UnknownSubjectMapping);
758        }
759        validate_runtime_identity(subject.as_str(), MAX_PROJECTION_SUBJECT_ID_BYTES)?;
760        let case_id = case_id.into();
761        validate_runtime_identity(&case_id, MAX_PROJECTION_RUNTIME_ID_BYTES)?;
762        let identity = (subject.clone(), case_id);
763        if self.cases.contains_key(&identity) {
764            return Err(AssessmentItemProjectionError::DuplicateCaseMapping);
765        }
766        let reference = AssessmentCaseReference::new(next_ordinal(self.cases.len(), "case")?);
767        self.cases.insert(identity, reference);
768        Ok(reference)
769    }
770
771    #[cfg(test)]
772    pub(crate) fn register_outcome(
773        &mut self,
774        outcome: &Outcome,
775    ) -> Result<AssessmentOutcomeReference, AssessmentItemProjectionError> {
776        check_projection_limit("outcomes", self.outcomes.len(), MAX_PROJECTION_OUTCOMES)?;
777        preflight_ordered_evidence_ids(outcome.evidence_ids())?;
778        validate_outcome_identity(outcome)?;
779        if !self.subjects.contains_key(outcome.subject()) {
780            return Err(AssessmentItemProjectionError::UnknownSubjectMapping);
781        }
782        if !self
783            .cases
784            .contains_key(&(outcome.subject().clone(), outcome.case_id().to_owned()))
785        {
786            return Err(AssessmentItemProjectionError::UnknownCaseMapping);
787        }
788        let identity = RuntimeOutcomeIdentity::from_outcome(outcome);
789        if self.outcomes.contains_key(&identity) {
790            return Err(AssessmentItemProjectionError::DuplicateOutcomeMapping);
791        }
792        let reference =
793            AssessmentOutcomeReference::new(next_ordinal(self.outcomes.len(), "outcome")?);
794        self.outcomes.insert(identity, reference);
795        Ok(reference)
796    }
797
798    pub(crate) fn register_evidence(
799        &mut self,
800        knowledge: &KnowledgeBase,
801        evidence_id: &EvidenceId,
802    ) -> Result<AssessmentEvidenceReference, AssessmentItemProjectionError> {
803        check_projection_limit("evidence", self.evidence.len(), MAX_PROJECTION_EVIDENCE)?;
804        self.validate_knowledge_authority(knowledge)?;
805        let evidence = knowledge
806            .evidence(evidence_id)
807            .ok_or(AssessmentItemProjectionError::EvidenceNotCommitted)?;
808        validate_runtime_identity(evidence_id.as_str(), MAX_PROJECTION_RUNTIME_ID_BYTES)?;
809        validate_runtime_identity(evidence.subject().as_str(), MAX_PROJECTION_SUBJECT_ID_BYTES)?;
810        if !self.subjects.contains_key(evidence.subject()) {
811            return Err(AssessmentItemProjectionError::UnknownSubjectMapping);
812        }
813        if self.evidence.contains_key(evidence.id()) {
814            return Err(AssessmentItemProjectionError::DuplicateEvidenceMapping);
815        }
816        let reference =
817            AssessmentEvidenceReference::new(next_ordinal(self.evidence.len(), "evidence")?);
818        self.evidence.insert(
819            evidence.id().clone(),
820            EvidenceProjection {
821                reference,
822                subject: evidence.subject().clone(),
823            },
824        );
825        Ok(reference)
826    }
827
828    #[cfg(feature = "wordpress-review")]
829    pub(super) fn has_evidence(&self, evidence_id: &EvidenceId) -> bool {
830        self.evidence.contains_key(evidence_id)
831    }
832
833    #[cfg(test)]
834    pub(crate) fn registered_evidence_count(&self) -> usize {
835        self.evidence.len()
836    }
837
838    #[cfg(test)]
839    pub(crate) fn evidence_reference_for(
840        &self,
841        evidence_id: &EvidenceId,
842    ) -> Option<AssessmentEvidenceReference> {
843        self.evidence
844            .get(evidence_id)
845            .map(|projection| projection.reference)
846    }
847
848    pub(super) fn preflight_evidence_projection(
849        &self,
850        knowledge: &KnowledgeBase,
851        subject: &EntityId,
852        target: &AssessmentItemTarget,
853        evidence_ids: &[EvidenceId],
854    ) -> Result<(), AssessmentItemProjectionError> {
855        self.validate_knowledge_authority(knowledge)?;
856        self.subject(subject, target)?;
857        preflight_evidence_ids(evidence_ids)?;
858        for evidence_id in evidence_ids {
859            let evidence = knowledge
860                .evidence(evidence_id)
861                .ok_or(AssessmentItemProjectionError::EvidenceNotCommitted)?;
862            validate_runtime_identity(
863                evidence.subject().as_str(),
864                MAX_PROJECTION_SUBJECT_ID_BYTES,
865            )?;
866            if evidence.subject() != subject {
867                return Err(AssessmentItemProjectionError::EvidenceMappingMismatch);
868            }
869        }
870        Ok(())
871    }
872
873    pub(crate) fn project_observation(
874        &mut self,
875        capability: &'static AssessmentCapabilityDescriptor,
876        knowledge: &KnowledgeBase,
877        subject: &EntityId,
878        target: &AssessmentItemTarget,
879        evidence_ids: &[EvidenceId],
880    ) -> Result<(), AssessmentItemProjectionError> {
881        check_projection_limit("items", self.items.len(), MAX_ASSESSMENT_ITEM_SET_ITEMS)?;
882        let item = AssessmentItem::from_observation(
883            capability,
884            self,
885            knowledge,
886            subject,
887            target,
888            evidence_ids,
889        )?;
890        self.push_item(item);
891        Ok(())
892    }
893
894    /// Projects one native, capability-authorized matched pair as review-only
895    /// product truth. This remains confined to the assessment runtime domain;
896    /// external callers and plugins cannot supply descriptors or choose a
897    /// disposition.
898    pub(super) fn project_differential(
899        &mut self,
900        capability: &'static AssessmentCapabilityDescriptor,
901        knowledge: &KnowledgeBase,
902        subject: &EntityId,
903        target: &AssessmentItemTarget,
904        control_ids: &[EvidenceId],
905        candidate_ids: &[EvidenceId],
906    ) -> Result<(), AssessmentItemProjectionError> {
907        check_projection_limit("items", self.items.len(), MAX_ASSESSMENT_ITEM_SET_ITEMS)?;
908        let item = AssessmentItem::from_differential(
909            capability,
910            self,
911            knowledge,
912            subject,
913            target,
914            control_ids,
915            candidate_ids,
916        )?;
917        self.push_item(item);
918        Ok(())
919    }
920
921    /// Projects the existing API comparator's one atomic paired-comparison
922    /// evidence record. The record remains owned by its isolated comparison
923    /// subject; this method binds it to the already registered root only after
924    /// replaying the canonical commit/review contract.
925    pub(super) fn project_api_visibility_paired_comparison(
926        &mut self,
927        capability: &'static AssessmentCapabilityDescriptor,
928        knowledge: &KnowledgeBase,
929        authorized_root_subject: &EntityId,
930        commit: &ApiObservationCommitReceipt,
931        review: &ApiVisibilityReview,
932    ) -> Result<(), AssessmentItemProjectionError> {
933        check_projection_limit("items", self.items.len(), MAX_ASSESSMENT_ITEM_SET_ITEMS)?;
934        check_projection_limit("evidence", self.evidence.len(), MAX_PROJECTION_EVIDENCE)?;
935        self.validate_knowledge_authority(knowledge)?;
936        if !capability.allows_differential_review()
937            || commit.resource_scope() != authorized_root_subject
938            || review.resource_scope() != authorized_root_subject
939            || review.comparison_subject() != commit.comparison_subject()
940            || review.relation_id() != commit.relation_id()
941            || review.evidence().id() != commit.evidence_id()
942            || review.evidence().subject() != commit.comparison_subject()
943            || review.disposition() != ApiVisibilityReviewDisposition::AwaitHumanReview
944            || review.boundary_hypotheses().len() != 1
945            || review.evidence().predicate()
946                != &ApiEvidencePredicate::JSON_AUTHORIZATION_CONTEXT_DIFFERENCE.into_knowledge()
947            || review.evidence().value() != &EvidenceValue::from(ApiVisibilityDimension::Fields)
948        {
949            return Err(AssessmentItemProjectionError::ApiVisibilityReviewContractMismatch);
950        }
951        let canonical = api_visibility_review_for_commit(knowledge, commit)
952            .ok_or(AssessmentItemProjectionError::ApiVisibilityReviewContractMismatch)?;
953        if &canonical != review {
954            return Err(AssessmentItemProjectionError::ApiVisibilityReviewContractMismatch);
955        }
956        validate_runtime_identity(
957            commit.comparison_subject().as_str(),
958            MAX_PROJECTION_SUBJECT_ID_BYTES,
959        )?;
960        validate_runtime_identity(
961            commit.evidence_id().as_str(),
962            MAX_PROJECTION_RUNTIME_ID_BYTES,
963        )?;
964        if self.evidence.contains_key(commit.evidence_id()) {
965            return Err(AssessmentItemProjectionError::DuplicateEvidenceMapping);
966        }
967        let committed = knowledge
968            .evidence(commit.evidence_id())
969            .ok_or(AssessmentItemProjectionError::EvidenceNotCommitted)?;
970        if &committed != review.evidence() || committed.subject() != commit.comparison_subject() {
971            return Err(AssessmentItemProjectionError::EvidenceMappingMismatch);
972        }
973        let target = AssessmentItemTarget::subject();
974        let subject_projection = self.subject(authorized_root_subject, &target)?;
975        let confidence = bounded_observation_confidence(
976            capability,
977            knowledge,
978            std::iter::once(commit.evidence_id()),
979        )?;
980        let reference =
981            AssessmentEvidenceReference::new(next_ordinal(self.evidence.len(), "evidence")?);
982        let item = AssessmentItem::build(
983            capability,
984            self.stable_scope_id(),
985            subject_projection,
986            &target,
987            confidence,
988            AssessmentBasis::Differential(AssessmentDifferentialBasis {
989                evidence: AssessmentDifferentialEvidence::PairedComparison(reference),
990            }),
991        );
992        self.evidence.insert(
993            commit.evidence_id().clone(),
994            EvidenceProjection {
995                reference,
996                subject: commit.comparison_subject().clone(),
997            },
998        );
999        self.push_item(item);
1000        Ok(())
1001    }
1002
1003    #[cfg(all(test, feature = "scanning"))]
1004    pub(crate) fn project_verifier(
1005        &mut self,
1006        capability: &'static AssessmentCapabilityDescriptor,
1007        target: &AssessmentItemTarget,
1008        receipt: &DecisionEvidenceReceipt,
1009        decision: &DecisionOutcomeReport,
1010        knowledge: &KnowledgeBase,
1011    ) -> Result<(), AssessmentItemProjectionError> {
1012        check_projection_limit("items", self.items.len(), MAX_ASSESSMENT_ITEM_SET_ITEMS)?;
1013        let item = AssessmentItem::from_verifier_projection(
1014            capability, self, target, receipt, decision, knowledge,
1015        )?;
1016        self.push_item(item);
1017        Ok(())
1018    }
1019
1020    pub(crate) fn finish(self) -> AssessmentItemSet {
1021        let mut subjects = self
1022            .subjects
1023            .values()
1024            .map(|subject| AssessmentSubjectInventoryEntry {
1025                reference: subject.reference,
1026                fingerprint: assessment_subject_fingerprint(
1027                    &self.stable_scope_id,
1028                    &subject.stable_id,
1029                ),
1030            })
1031            .collect::<Vec<_>>();
1032        subjects.sort_unstable_by_key(AssessmentSubjectInventoryEntry::reference);
1033        AssessmentItemSet {
1034            #[cfg(feature = "reporting")]
1035            stable_scope_id: self.stable_scope_id,
1036            #[cfg(feature = "reporting")]
1037            evidence_references: self
1038                .evidence
1039                .into_iter()
1040                .map(|(evidence_id, projection)| (evidence_id, projection.reference))
1041                .collect(),
1042            subjects,
1043            items: self.items,
1044        }
1045    }
1046
1047    fn push_item(&mut self, item: AssessmentItem) {
1048        self.items.push(item);
1049    }
1050
1051    fn subject(
1052        &self,
1053        subject: &EntityId,
1054        target: &AssessmentItemTarget,
1055    ) -> Result<&SubjectProjection, AssessmentItemProjectionError> {
1056        let projection = self
1057            .subjects
1058            .get(subject)
1059            .ok_or(AssessmentItemProjectionError::UnknownSubjectMapping)?;
1060        if let AssessmentItemTarget::QueryParameter(name) = target {
1061            if !projection.query_parameter_names.contains(name) {
1062                return Err(AssessmentItemProjectionError::UnknownQueryParameterTarget);
1063            }
1064        }
1065        Ok(projection)
1066    }
1067
1068    fn stable_scope_id(&self) -> &StableAssessmentScopeId {
1069        &self.stable_scope_id
1070    }
1071
1072    #[cfg(test)]
1073    fn case_reference(
1074        &self,
1075        subject: &EntityId,
1076        case_id: &str,
1077    ) -> Result<AssessmentCaseReference, AssessmentItemProjectionError> {
1078        self.cases
1079            .get(&(subject.clone(), case_id.to_owned()))
1080            .copied()
1081            .ok_or(AssessmentItemProjectionError::UnknownCaseMapping)
1082    }
1083
1084    #[cfg(test)]
1085    fn outcome_reference(
1086        &self,
1087        outcome: &Outcome,
1088    ) -> Result<AssessmentOutcomeReference, AssessmentItemProjectionError> {
1089        self.outcomes
1090            .get(&RuntimeOutcomeIdentity::from_outcome(outcome))
1091            .copied()
1092            .ok_or(AssessmentItemProjectionError::UnknownOutcomeMapping)
1093    }
1094
1095    fn evidence_references(
1096        &self,
1097        knowledge: &KnowledgeBase,
1098        subject: &EntityId,
1099        evidence_ids: &[EvidenceId],
1100    ) -> Result<Vec<AssessmentEvidenceReference>, AssessmentItemProjectionError> {
1101        self.validate_knowledge_authority(knowledge)?;
1102        validate_runtime_identity(subject.as_str(), MAX_PROJECTION_SUBJECT_ID_BYTES)?;
1103        preflight_evidence_ids(evidence_ids)?;
1104        evidence_ids
1105            .iter()
1106            .map(|evidence_id| {
1107                let projection = self
1108                    .evidence
1109                    .get(evidence_id)
1110                    .ok_or(AssessmentItemProjectionError::UnknownEvidenceMapping)?;
1111                if &projection.subject != subject {
1112                    return Err(AssessmentItemProjectionError::EvidenceSubjectMappingMismatch);
1113                }
1114                let committed = knowledge
1115                    .evidence(evidence_id)
1116                    .ok_or(AssessmentItemProjectionError::EvidenceNotCommitted)?;
1117                if committed.subject() != subject {
1118                    return Err(AssessmentItemProjectionError::EvidenceMappingMismatch);
1119                }
1120                Ok(projection.reference)
1121            })
1122            .collect()
1123    }
1124
1125    fn validate_knowledge_authority(
1126        &self,
1127        knowledge: &KnowledgeBase,
1128    ) -> Result<(), AssessmentItemProjectionError> {
1129        if knowledge.authority().is_same_as(&self.knowledge_authority) {
1130            Ok(())
1131        } else {
1132            Err(AssessmentItemProjectionError::KnowledgeAuthorityMismatch)
1133        }
1134    }
1135}
1136
1137#[cfg(test)]
1138fn validate_outcome_identity(outcome: &Outcome) -> Result<(), AssessmentItemProjectionError> {
1139    validate_runtime_identity(outcome.subject().as_str(), MAX_PROJECTION_SUBJECT_ID_BYTES)?;
1140    for identity in [
1141        outcome.case_id(),
1142        outcome.action_id(),
1143        outcome.hypothesis_id(),
1144    ] {
1145        validate_runtime_identity(identity, MAX_PROJECTION_RUNTIME_ID_BYTES)?;
1146    }
1147    if let Some(verifier_rule_id) = outcome.verifier_rule_id() {
1148        validate_runtime_identity(verifier_rule_id, MAX_PROJECTION_RUNTIME_ID_BYTES)?;
1149    }
1150    for evidence_id in outcome.evidence_ids() {
1151        validate_runtime_identity(evidence_id.as_str(), MAX_PROJECTION_RUNTIME_ID_BYTES)?;
1152    }
1153    Ok(())
1154}
1155
1156fn validate_runtime_identity(
1157    identity: &str,
1158    maximum: usize,
1159) -> Result<(), AssessmentItemProjectionError> {
1160    if identity.is_empty() || identity.len() > maximum {
1161        Err(AssessmentItemProjectionError::InvalidRuntimeIdentity)
1162    } else {
1163        Ok(())
1164    }
1165}
1166
1167fn preflight_evidence_ids(
1168    evidence_ids: &[EvidenceId],
1169) -> Result<(), AssessmentItemProjectionError> {
1170    if evidence_ids.is_empty() {
1171        return Err(AssessmentItemProjectionError::MissingEvidence);
1172    }
1173    if evidence_ids.len() > MAX_ASSESSMENT_ITEM_EVIDENCE_REFERENCES {
1174        return Err(AssessmentItemProjectionError::TooManyEvidenceReferences);
1175    }
1176    let mut seen = BTreeSet::new();
1177    for evidence_id in evidence_ids {
1178        validate_runtime_identity(evidence_id.as_str(), MAX_PROJECTION_RUNTIME_ID_BYTES)?;
1179        if !seen.insert(evidence_id) {
1180            return Err(AssessmentItemProjectionError::DuplicateEvidenceReference);
1181        }
1182    }
1183    Ok(())
1184}
1185
1186#[cfg(test)]
1187fn preflight_ordered_evidence_ids(
1188    evidence_ids: &BTreeSet<EvidenceId>,
1189) -> Result<(), AssessmentItemProjectionError> {
1190    if evidence_ids.is_empty() {
1191        return Err(AssessmentItemProjectionError::MissingEvidence);
1192    }
1193    if evidence_ids.len() > MAX_ASSESSMENT_ITEM_EVIDENCE_REFERENCES {
1194        return Err(AssessmentItemProjectionError::TooManyEvidenceReferences);
1195    }
1196    for evidence_id in evidence_ids {
1197        validate_runtime_identity(evidence_id.as_str(), MAX_PROJECTION_RUNTIME_ID_BYTES)?;
1198    }
1199    Ok(())
1200}
1201
1202fn next_ordinal(
1203    current_len: usize,
1204    kind: &'static str,
1205) -> Result<u32, AssessmentItemProjectionError> {
1206    u32::try_from(current_len)
1207        .map_err(|_| AssessmentItemProjectionError::ReferenceSpaceExhausted { kind })
1208}
1209
1210fn check_projection_limit(
1211    dimension: &'static str,
1212    current_len: usize,
1213    maximum: usize,
1214) -> Result<(), AssessmentItemProjectionError> {
1215    if current_len >= maximum {
1216        Err(AssessmentItemProjectionError::ProjectionContextLimit { dimension, maximum })
1217    } else {
1218        Ok(())
1219    }
1220}
1221
1222/// Static remediation metadata owned by a native capability.
1223#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1224pub struct AssessmentRemediation {
1225    id: &'static str,
1226    summary: &'static str,
1227}
1228
1229impl AssessmentRemediation {
1230    /// Returns the stable remediation policy identifier.
1231    pub const fn id(self) -> &'static str {
1232        self.id
1233    }
1234
1235    /// Returns the bounded static remediation summary.
1236    pub const fn summary(self) -> &'static str {
1237        self.summary
1238    }
1239}
1240
1241/// Evidence basis for an informational observation.
1242#[derive(Debug, Clone, PartialEq, Eq)]
1243pub struct AssessmentObservationBasis {
1244    evidence: Vec<AssessmentEvidenceReference>,
1245}
1246
1247impl AssessmentObservationBasis {
1248    /// Returns opaque evidence references in ascending ordinal order.
1249    pub fn evidence(&self) -> &[AssessmentEvidenceReference] {
1250        &self.evidence
1251    }
1252}
1253
1254#[derive(Debug, Clone, PartialEq, Eq)]
1255enum AssessmentDifferentialEvidence {
1256    MatchedPair {
1257        control: Vec<AssessmentEvidenceReference>,
1258        candidate: Vec<AssessmentEvidenceReference>,
1259    },
1260    PairedComparison(AssessmentEvidenceReference),
1261}
1262
1263/// Matched or atomically paired evidence basis for a review item.
1264#[derive(Debug, Clone, PartialEq, Eq)]
1265pub struct AssessmentDifferentialBasis {
1266    evidence: AssessmentDifferentialEvidence,
1267}
1268
1269impl AssessmentDifferentialBasis {
1270    /// Returns opaque negative/control evidence references.
1271    pub fn control(&self) -> &[AssessmentEvidenceReference] {
1272        match &self.evidence {
1273            AssessmentDifferentialEvidence::MatchedPair { control, .. } => control,
1274            AssessmentDifferentialEvidence::PairedComparison(_) => &[],
1275        }
1276    }
1277
1278    /// Returns opaque candidate evidence references.
1279    pub fn candidate(&self) -> &[AssessmentEvidenceReference] {
1280        match &self.evidence {
1281            AssessmentDifferentialEvidence::MatchedPair { candidate, .. } => candidate,
1282            AssessmentDifferentialEvidence::PairedComparison(_) => &[],
1283        }
1284    }
1285
1286    /// Returns the single canonical comparison evidence when the comparator
1287    /// committed an atomic pair instead of two independently stored legs.
1288    pub const fn paired_comparison(&self) -> Option<AssessmentEvidenceReference> {
1289        match self.evidence {
1290            AssessmentDifferentialEvidence::PairedComparison(reference) => Some(reference),
1291            AssessmentDifferentialEvidence::MatchedPair { .. } => None,
1292        }
1293    }
1294
1295    fn evidence_count(&self) -> usize {
1296        match &self.evidence {
1297            AssessmentDifferentialEvidence::MatchedPair { control, candidate } => {
1298                control.len() + candidate.len()
1299            },
1300            AssessmentDifferentialEvidence::PairedComparison(_) => 1,
1301        }
1302    }
1303}
1304
1305/// Verifier-owned basis for a confirmed item.
1306#[derive(Debug, Clone, PartialEq, Eq)]
1307pub struct AssessmentVerifierBasis {
1308    case_reference: AssessmentCaseReference,
1309    outcome_reference: AssessmentOutcomeReference,
1310    verifier_rule_id: &'static str,
1311    stage: VerificationStage,
1312    evidence: Vec<AssessmentEvidenceReference>,
1313}
1314
1315impl AssessmentVerifierBasis {
1316    /// Returns the opaque case reference.
1317    pub const fn case_reference(&self) -> AssessmentCaseReference {
1318        self.case_reference
1319    }
1320
1321    /// Returns the opaque outcome reference.
1322    pub const fn outcome_reference(&self) -> AssessmentOutcomeReference {
1323        self.outcome_reference
1324    }
1325
1326    /// Returns the native verifier rule identity declared by the capability.
1327    pub const fn verifier_rule_id(&self) -> &'static str {
1328        self.verifier_rule_id
1329    }
1330
1331    /// Returns the evidence collection stage.
1332    pub const fn stage(&self) -> VerificationStage {
1333        self.stage
1334    }
1335
1336    /// Returns opaque contributing evidence references.
1337    pub fn evidence(&self) -> &[AssessmentEvidenceReference] {
1338        &self.evidence
1339    }
1340}
1341
1342/// Typed authority that produced an assessment item.
1343#[derive(Debug, Clone, PartialEq, Eq)]
1344#[non_exhaustive]
1345pub enum AssessmentBasis {
1346    /// One or more bounded observations; never a confirmation basis.
1347    Observation(AssessmentObservationBasis),
1348    /// A matched control/candidate relationship; review only.
1349    Differential(AssessmentDifferentialBasis),
1350    /// A case-correlated verifier transition.
1351    Verifier(AssessmentVerifierBasis),
1352}
1353
1354impl AssessmentBasis {
1355    const fn disposition(&self) -> AssessmentDisposition {
1356        match self {
1357            Self::Observation(_) => AssessmentDisposition::Informational,
1358            Self::Differential(_) => AssessmentDisposition::NeedsReview,
1359            Self::Verifier(_) => AssessmentDisposition::Confirmed,
1360        }
1361    }
1362
1363    /// Returns the total number of opaque evidence references.
1364    pub fn evidence_count(&self) -> usize {
1365        match self {
1366            Self::Observation(basis) => basis.evidence.len(),
1367            Self::Differential(basis) => basis.evidence_count(),
1368            Self::Verifier(basis) => basis.evidence.len(),
1369        }
1370    }
1371
1372    /// Returns a verifier case reference only for verifier-owned items.
1373    pub const fn case_reference(&self) -> Option<AssessmentCaseReference> {
1374        match self {
1375            Self::Verifier(basis) => Some(basis.case_reference),
1376            Self::Observation(_) | Self::Differential(_) => None,
1377        }
1378    }
1379}
1380
1381/// Read-only product projection from deterministic runtime truth.
1382///
1383/// The type deliberately has no general serialization implementation and no
1384/// public constructor. It is reserved for the later bounded report projection;
1385/// plugins and arbitrary callers cannot select a disposition or severity.
1386#[derive(Debug, Clone, PartialEq, Eq)]
1387pub struct AssessmentItem {
1388    capability: &'static AssessmentCapabilityDescriptor,
1389    subject_reference: AssessmentSubjectReference,
1390    presentation_target: AssessmentItemPresentationTarget,
1391    confidence: Probability,
1392    fingerprint: String,
1393    basis: AssessmentBasis,
1394}
1395
1396impl AssessmentItem {
1397    /// Returns the stable item schema.
1398    pub const fn schema(&self) -> &'static str {
1399        ASSESSMENT_ITEM_SCHEMA
1400    }
1401
1402    /// Returns the native capability identity.
1403    pub const fn capability_id(&self) -> &'static str {
1404        self.capability.id
1405    }
1406
1407    /// Returns the bounded static title.
1408    pub const fn title(&self) -> &'static str {
1409        self.capability.title
1410    }
1411
1412    /// Returns the opaque canonical-subject reference.
1413    pub const fn subject_reference(&self) -> AssessmentSubjectReference {
1414        self.subject_reference
1415    }
1416
1417    /// Returns a value-free, closed description of the assessment target kind.
1418    pub(crate) const fn presentation_target_kind(&self) -> &'static str {
1419        self.presentation_target.as_str()
1420    }
1421
1422    /// Returns the product disposition.
1423    pub const fn disposition(&self) -> AssessmentDisposition {
1424        self.basis.disposition()
1425    }
1426
1427    /// Returns capability-owned optional severity.
1428    pub const fn severity(&self) -> Option<SecuritySeverity> {
1429        self.capability.severity
1430    }
1431
1432    /// Returns fixed-point confidence without interpreting it as CVSS.
1433    pub const fn confidence(&self) -> Probability {
1434        self.confidence
1435    }
1436
1437    /// Returns the versioned stable fingerprint.
1438    pub fn fingerprint(&self) -> &str {
1439        &self.fingerprint
1440    }
1441
1442    /// Returns the typed observation/differential/verifier basis.
1443    pub const fn basis(&self) -> &AssessmentBasis {
1444        &self.basis
1445    }
1446
1447    /// Returns the bounded number of opaque evidence references.
1448    pub fn evidence_count(&self) -> usize {
1449        self.basis.evidence_count()
1450    }
1451
1452    /// Returns the already-redacted static summary.
1453    pub const fn redacted_summary(&self) -> &'static str {
1454        self.capability.redacted_summary
1455    }
1456
1457    /// Returns static capability category metadata.
1458    pub const fn category(&self) -> &'static str {
1459        self.capability.category
1460    }
1461
1462    /// Returns a CWE only when the native capability maps cleanly to one.
1463    pub const fn cwe(&self) -> Option<&'static str> {
1464        self.capability.cwe
1465    }
1466
1467    /// Returns capability-owned remediation metadata.
1468    pub const fn remediation(&self) -> AssessmentRemediation {
1469        self.capability.remediation
1470    }
1471
1472    fn from_observation(
1473        capability: &'static AssessmentCapabilityDescriptor,
1474        context: &AssessmentProjectionContext,
1475        knowledge: &KnowledgeBase,
1476        subject: &EntityId,
1477        target: &AssessmentItemTarget,
1478        evidence_ids: &[EvidenceId],
1479    ) -> Result<Self, AssessmentItemProjectionError> {
1480        preflight_evidence_ids(evidence_ids)?;
1481        let subject_projection = context.subject(subject, target)?;
1482        let evidence = context.evidence_references(knowledge, subject, evidence_ids)?;
1483        let evidence = validate_reference_set("observation", evidence)?;
1484        let confidence = bounded_observation_confidence(capability, knowledge, evidence_ids)?;
1485        Ok(Self::build(
1486            capability,
1487            context.stable_scope_id(),
1488            subject_projection,
1489            target,
1490            confidence,
1491            AssessmentBasis::Observation(AssessmentObservationBasis { evidence }),
1492        ))
1493    }
1494
1495    // Deliberately private behind `AssessmentProjectionContext`: native
1496    // matched-pair validators can request review projection without receiving
1497    // a raw item-construction or disposition-selection surface.
1498    fn from_differential(
1499        capability: &'static AssessmentCapabilityDescriptor,
1500        context: &AssessmentProjectionContext,
1501        knowledge: &KnowledgeBase,
1502        subject: &EntityId,
1503        target: &AssessmentItemTarget,
1504        control_ids: &[EvidenceId],
1505        candidate_ids: &[EvidenceId],
1506    ) -> Result<Self, AssessmentItemProjectionError> {
1507        if !capability.allows_differential_review() {
1508            return Err(AssessmentItemProjectionError::DispositionDenied {
1509                requested: AssessmentDisposition::NeedsReview,
1510            });
1511        }
1512        preflight_evidence_ids(control_ids)?;
1513        preflight_evidence_ids(candidate_ids)?;
1514        if control_ids.len().saturating_add(candidate_ids.len())
1515            > MAX_ASSESSMENT_ITEM_EVIDENCE_REFERENCES
1516        {
1517            return Err(AssessmentItemProjectionError::TooManyEvidenceReferences);
1518        }
1519        let control_identity = control_ids.iter().collect::<BTreeSet<_>>();
1520        if candidate_ids
1521            .iter()
1522            .any(|evidence_id| control_identity.contains(evidence_id))
1523        {
1524            return Err(AssessmentItemProjectionError::OverlappingDifferentialEvidence);
1525        }
1526        let subject_projection = context.subject(subject, target)?;
1527        let control = context.evidence_references(knowledge, subject, control_ids)?;
1528        let candidate = context.evidence_references(knowledge, subject, candidate_ids)?;
1529        let control = validate_reference_set("differential control", control)?;
1530        let candidate = validate_reference_set("differential candidate", candidate)?;
1531        if control.len().saturating_add(candidate.len()) > MAX_ASSESSMENT_ITEM_EVIDENCE_REFERENCES {
1532            return Err(AssessmentItemProjectionError::TooManyEvidenceReferences);
1533        }
1534        if control
1535            .iter()
1536            .any(|reference| candidate.contains(reference))
1537        {
1538            return Err(AssessmentItemProjectionError::OverlappingDifferentialEvidence);
1539        }
1540        let confidence = bounded_observation_confidence(
1541            capability,
1542            knowledge,
1543            control_ids.iter().chain(candidate_ids),
1544        )?;
1545        Ok(Self::build(
1546            capability,
1547            context.stable_scope_id(),
1548            subject_projection,
1549            target,
1550            confidence,
1551            AssessmentBasis::Differential(AssessmentDifferentialBasis {
1552                evidence: AssessmentDifferentialEvidence::MatchedPair { control, candidate },
1553            }),
1554        ))
1555    }
1556
1557    #[cfg(test)]
1558    fn from_verifier_projection(
1559        capability: &'static AssessmentCapabilityDescriptor,
1560        context: &AssessmentProjectionContext,
1561        target: &AssessmentItemTarget,
1562        receipt: &DecisionEvidenceReceipt,
1563        decision: &DecisionOutcomeReport,
1564        knowledge: &KnowledgeBase,
1565    ) -> Result<Self, AssessmentItemProjectionError> {
1566        let outcome = decision.verification().outcome();
1567        preflight_ordered_evidence_ids(outcome.evidence_ids())?;
1568        let extraction = extract_confirmation_proof(capability, receipt, decision, knowledge);
1569        extraction.proof.authorize()?;
1570
1571        validate_outcome_identity(outcome)?;
1572        let subject_projection = context.subject(outcome.subject(), target)?;
1573        let case_reference = context.case_reference(outcome.subject(), outcome.case_id())?;
1574        let outcome_reference = context.outcome_reference(outcome)?;
1575        let evidence_ids = extraction.evidence_ids.iter().cloned().collect::<Vec<_>>();
1576        let projected = context.evidence_references(knowledge, outcome.subject(), &evidence_ids)?;
1577        let evidence = validate_reference_set("verifier", projected)?;
1578        let policy = capability.verifier_policy().ok_or(
1579            AssessmentItemProjectionError::ConfirmationDenied(
1580                AssessmentConfirmationDenial::CapabilityPolicy,
1581            ),
1582        )?;
1583        let confidence =
1584            bounded_observation_confidence(capability, knowledge, extraction.evidence_ids.iter())?
1585                .min(outcome.confidence());
1586        Ok(Self::build(
1587            capability,
1588            context.stable_scope_id(),
1589            subject_projection,
1590            target,
1591            confidence,
1592            AssessmentBasis::Verifier(AssessmentVerifierBasis {
1593                case_reference,
1594                outcome_reference,
1595                verifier_rule_id: policy.verifier_rule_id,
1596                stage: policy.stage,
1597                evidence,
1598            }),
1599        ))
1600    }
1601
1602    fn build(
1603        capability: &'static AssessmentCapabilityDescriptor,
1604        stable_scope_id: &StableAssessmentScopeId,
1605        subject: &SubjectProjection,
1606        target: &AssessmentItemTarget,
1607        confidence: Probability,
1608        basis: AssessmentBasis,
1609    ) -> Self {
1610        Self {
1611            capability,
1612            subject_reference: subject.reference,
1613            presentation_target: target.presentation_target(),
1614            confidence,
1615            fingerprint: assessment_fingerprint(
1616                capability.id,
1617                stable_scope_id,
1618                &subject.stable_id,
1619                target,
1620            ),
1621            basis,
1622        }
1623    }
1624}
1625
1626/// Stable reason a verifier outcome was denied confirmation authority.
1627#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1628#[non_exhaustive]
1629pub enum AssessmentConfirmationDenial {
1630    CapabilityPolicy,
1631    ActionMismatch,
1632    HypothesisClaimMismatch,
1633    OutcomeNotSuccess,
1634    KnowledgeOnly,
1635    MissingHypothesisWrite,
1636    FinalHypothesisNotConfirmed,
1637    CaseMismatch,
1638    SelectedVerifierMismatch,
1639    MissingEvidence,
1640    EvidenceUnavailable,
1641    EvidenceSubjectMismatch,
1642    EvidenceCaseMismatch,
1643    ReceiptCaseMismatch,
1644    ReceiptStageMismatch,
1645    ReceiptDidNotContribute,
1646    ReceiptEvidenceMismatch,
1647}
1648
1649impl fmt::Display for AssessmentConfirmationDenial {
1650    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
1651        formatter.write_str(match self {
1652            Self::CapabilityPolicy => "capability policy does not authorize confirmation",
1653            Self::ActionMismatch => "action identity does not match capability claim policy",
1654            Self::HypothesisClaimMismatch => {
1655                "hypothesis predicate or value does not match capability claim policy"
1656            },
1657            Self::OutcomeNotSuccess => "verifier outcome is not success",
1658            Self::KnowledgeOnly => "verification case is knowledge-only",
1659            Self::MissingHypothesisWrite => "verifier transition write is absent",
1660            Self::FinalHypothesisNotConfirmed => "final hypothesis state is not confirmed",
1661            Self::CaseMismatch => "case and outcome identities do not agree",
1662            Self::SelectedVerifierMismatch => "selected verifier does not match capability policy",
1663            Self::MissingEvidence => "verifier outcome has no evidence",
1664            Self::EvidenceUnavailable => "verifier evidence is absent from committed knowledge",
1665            Self::EvidenceSubjectMismatch => "verifier evidence belongs to another subject",
1666            Self::EvidenceCaseMismatch => "verifier evidence belongs to another case",
1667            Self::ReceiptCaseMismatch => "execution receipt belongs to another case",
1668            Self::ReceiptStageMismatch => "execution receipt stage does not match verification",
1669            Self::ReceiptDidNotContribute => {
1670                "execution receipt does not contain the required contributing evidence"
1671            },
1672            Self::ReceiptEvidenceMismatch => {
1673                "execution receipt evidence differs from the committed knowledge record"
1674            },
1675        })
1676    }
1677}
1678
1679/// Fail-closed projection errors.
1680#[derive(Debug, Clone, PartialEq, Eq, Error)]
1681#[non_exhaustive]
1682pub enum AssessmentItemProjectionError {
1683    #[error("assessment capability does not authorize {requested:?}")]
1684    DispositionDenied { requested: AssessmentDisposition },
1685    #[error("assessment item requires at least one evidence reference")]
1686    MissingEvidence,
1687    #[error("assessment item exceeds its evidence-reference limit")]
1688    TooManyEvidenceReferences,
1689    #[error("assessment item contains a duplicate evidence reference")]
1690    DuplicateEvidenceReference,
1691    #[error("differential control and candidate evidence overlap")]
1692    OverlappingDifferentialEvidence,
1693    #[error("API visibility review does not match its canonical committed comparison")]
1694    ApiVisibilityReviewContractMismatch,
1695    #[error("assessment confirmation denied: {0}")]
1696    ConfirmationDenied(AssessmentConfirmationDenial),
1697    #[error("static capability confidence is outside the fixed-point range")]
1698    InvalidCapabilityConfidence,
1699    #[error("host-approved stable subject identity is invalid")]
1700    InvalidStableSubjectIdentity,
1701    #[error("host-approved stable assessment scope identity is invalid")]
1702    InvalidStableScopeIdentity,
1703    #[error("query-parameter target identity is invalid")]
1704    InvalidQueryParameterTarget,
1705    #[error("query-parameter target is not registered for the runtime subject")]
1706    UnknownQueryParameterTarget,
1707    #[error("query-parameter target is duplicated in one subject mapping")]
1708    DuplicateQueryParameterTarget,
1709    #[error("runtime identity is invalid")]
1710    InvalidRuntimeIdentity,
1711    #[error("runtime subject has no projection mapping")]
1712    UnknownSubjectMapping,
1713    #[error("runtime verification case has no projection mapping")]
1714    UnknownCaseMapping,
1715    #[error("runtime verifier outcome has no projection mapping")]
1716    UnknownOutcomeMapping,
1717    #[error("runtime evidence has no projection mapping")]
1718    UnknownEvidenceMapping,
1719    #[error("runtime evidence was not committed to knowledge")]
1720    EvidenceNotCommitted,
1721    #[error("runtime evidence mapping differs from committed knowledge")]
1722    EvidenceMappingMismatch,
1723    #[error("projection context belongs to another knowledge authority")]
1724    KnowledgeAuthorityMismatch,
1725    #[error("runtime subject already has a projection mapping")]
1726    DuplicateSubjectMapping,
1727    #[error("stable subject identity is already bound to another runtime subject")]
1728    DuplicateStableSubjectIdentity,
1729    #[error("runtime verification case already has a projection mapping")]
1730    DuplicateCaseMapping,
1731    #[error("runtime verifier outcome already has a projection mapping")]
1732    DuplicateOutcomeMapping,
1733    #[error("runtime evidence already has a projection mapping")]
1734    DuplicateEvidenceMapping,
1735    #[error("runtime evidence projection belongs to another subject")]
1736    EvidenceSubjectMappingMismatch,
1737    #[error("assessment {kind} reference space is exhausted")]
1738    ReferenceSpaceExhausted { kind: &'static str },
1739    #[error("assessment projection {dimension} exceeds compiled maximum {maximum}")]
1740    ProjectionContextLimit {
1741        dimension: &'static str,
1742        maximum: usize,
1743    },
1744}
1745
1746#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1747enum AssessmentClaimPolicy {
1748    ObservationOnly,
1749    DifferentialReview,
1750    #[cfg(test)]
1751    VerifierTransition(VerifierClaimPolicy),
1752}
1753
1754#[cfg(test)]
1755impl AssessmentClaimPolicy {
1756    const fn maximum_disposition(self) -> AssessmentDisposition {
1757        match self {
1758            Self::ObservationOnly => AssessmentDisposition::Informational,
1759            Self::DifferentialReview => AssessmentDisposition::NeedsReview,
1760            Self::VerifierTransition(_) => AssessmentDisposition::Confirmed,
1761        }
1762    }
1763}
1764
1765#[cfg(test)]
1766#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1767struct VerifierClaimPolicy {
1768    action_id: &'static str,
1769    hypothesis_namespace: &'static str,
1770    hypothesis_name: &'static str,
1771    hypothesis_value: StaticEvidenceValue,
1772    verifier_rule_id: &'static str,
1773    stage: VerificationStage,
1774}
1775
1776#[cfg(test)]
1777#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1778enum StaticEvidenceValue {
1779    Boolean(bool),
1780    Unsigned(u64),
1781    Text(&'static str),
1782}
1783
1784#[cfg(test)]
1785impl StaticEvidenceValue {
1786    fn matches(self, value: &EvidenceValue) -> bool {
1787        match (self, value) {
1788            (Self::Boolean(expected), EvidenceValue::Boolean(actual)) => expected == *actual,
1789            (Self::Unsigned(expected), EvidenceValue::Unsigned(actual)) => expected == *actual,
1790            (Self::Text(expected), EvidenceValue::Text(actual)) => expected == actual.as_str(),
1791            _ => false,
1792        }
1793    }
1794}
1795
1796/// Native static metadata. This remains crate-private so plugins and arbitrary
1797/// callers cannot mint capability identities, severities, or claim policies.
1798#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1799pub(crate) struct AssessmentCapabilityDescriptor {
1800    id: &'static str,
1801    title: &'static str,
1802    category: &'static str,
1803    redacted_summary: &'static str,
1804    severity: Option<SecuritySeverity>,
1805    confidence_ppm: u32,
1806    cwe: Option<&'static str>,
1807    remediation: AssessmentRemediation,
1808    claim_policy: AssessmentClaimPolicy,
1809}
1810
1811impl AssessmentCapabilityDescriptor {
1812    /// Defines one native observation-only capability. This surface cannot
1813    /// assign severity/CWE metadata or authorize review/confirmation.
1814    #[allow(clippy::too_many_arguments)]
1815    pub(crate) const fn informational(
1816        id: &'static str,
1817        title: &'static str,
1818        category: &'static str,
1819        redacted_summary: &'static str,
1820        confidence_ppm: u32,
1821        remediation_id: &'static str,
1822        remediation_summary: &'static str,
1823    ) -> Self {
1824        Self::new(
1825            id,
1826            title,
1827            category,
1828            redacted_summary,
1829            None,
1830            confidence_ppm,
1831            None,
1832            AssessmentRemediation {
1833                id: remediation_id,
1834                summary: remediation_summary,
1835            },
1836            AssessmentClaimPolicy::ObservationOnly,
1837        )
1838    }
1839
1840    /// Defines one native matched-pair capability whose strongest product
1841    /// disposition is `NeedsReview`. The constructor is visible only within
1842    /// the parent assessment-runtime domain, so plugins and unrelated crate
1843    /// modules cannot mint review descriptors or select claim policy.
1844    #[allow(clippy::too_many_arguments)]
1845    pub(super) const fn differential_review(
1846        id: &'static str,
1847        title: &'static str,
1848        category: &'static str,
1849        redacted_summary: &'static str,
1850        severity: Option<SecuritySeverity>,
1851        confidence_ppm: u32,
1852        cwe: Option<&'static str>,
1853        remediation_id: &'static str,
1854        remediation_summary: &'static str,
1855    ) -> Self {
1856        Self::new(
1857            id,
1858            title,
1859            category,
1860            redacted_summary,
1861            severity,
1862            confidence_ppm,
1863            cwe,
1864            AssessmentRemediation {
1865                id: remediation_id,
1866                summary: remediation_summary,
1867            },
1868            AssessmentClaimPolicy::DifferentialReview,
1869        )
1870    }
1871
1872    #[allow(clippy::too_many_arguments)]
1873    const fn new(
1874        id: &'static str,
1875        title: &'static str,
1876        category: &'static str,
1877        redacted_summary: &'static str,
1878        severity: Option<SecuritySeverity>,
1879        confidence_ppm: u32,
1880        cwe: Option<&'static str>,
1881        remediation: AssessmentRemediation,
1882        claim_policy: AssessmentClaimPolicy,
1883    ) -> Self {
1884        assert!(!id.is_empty() && id.len() <= MAX_ASSESSMENT_CAPABILITY_ID_BYTES);
1885        assert!(!title.is_empty() && title.len() <= MAX_ASSESSMENT_DISPLAY_BYTES);
1886        assert!(!category.is_empty() && category.len() <= MAX_ASSESSMENT_DISPLAY_BYTES);
1887        assert!(
1888            !redacted_summary.is_empty() && redacted_summary.len() <= MAX_ASSESSMENT_DISPLAY_BYTES
1889        );
1890        assert!(
1891            !remediation.id.is_empty()
1892                && remediation.id.len() <= MAX_ASSESSMENT_CAPABILITY_ID_BYTES
1893        );
1894        assert!(
1895            !remediation.summary.is_empty()
1896                && remediation.summary.len() <= MAX_ASSESSMENT_DISPLAY_BYTES
1897        );
1898        assert!(confidence_ppm <= 1_000_000);
1899        if let Some(cwe) = cwe {
1900            assert!(!cwe.is_empty() && cwe.len() <= MAX_ASSESSMENT_CAPABILITY_ID_BYTES);
1901        }
1902        match claim_policy {
1903            AssessmentClaimPolicy::ObservationOnly => {},
1904            AssessmentClaimPolicy::DifferentialReview => {},
1905            #[cfg(test)]
1906            AssessmentClaimPolicy::VerifierTransition(policy) => {
1907                assert!(
1908                    !policy.action_id.is_empty()
1909                        && policy.action_id.len() <= MAX_ASSESSMENT_CAPABILITY_ID_BYTES
1910                );
1911                assert!(
1912                    !policy.hypothesis_namespace.is_empty()
1913                        && policy.hypothesis_namespace.len() <= MAX_ASSESSMENT_CAPABILITY_ID_BYTES
1914                );
1915                assert!(
1916                    !policy.hypothesis_name.is_empty()
1917                        && policy.hypothesis_name.len() <= MAX_ASSESSMENT_CAPABILITY_ID_BYTES
1918                );
1919                assert!(
1920                    !policy.verifier_rule_id.is_empty()
1921                        && policy.verifier_rule_id.len() <= MAX_ASSESSMENT_CAPABILITY_ID_BYTES
1922                );
1923                if let StaticEvidenceValue::Text(value) = policy.hypothesis_value {
1924                    assert!(!value.is_empty() && value.len() <= MAX_ASSESSMENT_DISPLAY_BYTES);
1925                }
1926            },
1927        }
1928        Self {
1929            id,
1930            title,
1931            category,
1932            redacted_summary,
1933            severity,
1934            confidence_ppm,
1935            cwe,
1936            remediation,
1937            claim_policy,
1938        }
1939    }
1940
1941    fn confidence(self) -> Result<Probability, AssessmentItemProjectionError> {
1942        Probability::from_parts_per_million(self.confidence_ppm)
1943            .map_err(|_| AssessmentItemProjectionError::InvalidCapabilityConfidence)
1944    }
1945
1946    #[cfg(test)]
1947    const fn maximum_disposition(self) -> AssessmentDisposition {
1948        self.claim_policy.maximum_disposition()
1949    }
1950
1951    const fn allows_differential_review(self) -> bool {
1952        match self.claim_policy {
1953            AssessmentClaimPolicy::ObservationOnly => false,
1954            AssessmentClaimPolicy::DifferentialReview => true,
1955            #[cfg(test)]
1956            AssessmentClaimPolicy::VerifierTransition(_) => true,
1957        }
1958    }
1959
1960    #[cfg(test)]
1961    const fn verifier_policy(self) -> Option<VerifierClaimPolicy> {
1962        match self.claim_policy {
1963            AssessmentClaimPolicy::VerifierTransition(policy) => Some(policy),
1964            AssessmentClaimPolicy::ObservationOnly | AssessmentClaimPolicy::DifferentialReview => {
1965                None
1966            },
1967        }
1968    }
1969}
1970
1971fn validate_reference_set(
1972    _kind: &'static str,
1973    mut references: Vec<AssessmentEvidenceReference>,
1974) -> Result<Vec<AssessmentEvidenceReference>, AssessmentItemProjectionError> {
1975    if references.is_empty() {
1976        return Err(AssessmentItemProjectionError::MissingEvidence);
1977    }
1978    if references.len() > MAX_ASSESSMENT_ITEM_EVIDENCE_REFERENCES {
1979        return Err(AssessmentItemProjectionError::TooManyEvidenceReferences);
1980    }
1981    references.sort_unstable();
1982    if references.windows(2).any(|pair| pair[0] == pair[1]) {
1983        return Err(AssessmentItemProjectionError::DuplicateEvidenceReference);
1984    }
1985    Ok(references)
1986}
1987
1988fn bounded_observation_confidence<'a>(
1989    capability: &'static AssessmentCapabilityDescriptor,
1990    knowledge: &KnowledgeBase,
1991    evidence_ids: impl IntoIterator<Item = &'a EvidenceId>,
1992) -> Result<Probability, AssessmentItemProjectionError> {
1993    let mut confidence = capability.confidence()?;
1994    for evidence_id in evidence_ids {
1995        let evidence = knowledge
1996            .evidence(evidence_id)
1997            .ok_or(AssessmentItemProjectionError::EvidenceNotCommitted)?;
1998        let evidence_confidence =
1999            Probability::from_basis_points(evidence.reliability().basis_points())
2000                .map_err(|_| AssessmentItemProjectionError::InvalidCapabilityConfidence)?;
2001        confidence = confidence.min(evidence_confidence);
2002    }
2003    Ok(confidence)
2004}
2005
2006fn assessment_fingerprint(
2007    capability_id: &str,
2008    stable_scope_id: &StableAssessmentScopeId,
2009    stable_subject_id: &StableAssessmentSubjectId,
2010    target: &AssessmentItemTarget,
2011) -> String {
2012    let mut digest = Sha256::new();
2013    digest.update(FINGERPRINT_DOMAIN);
2014    digest_field(&mut digest, ASSESSMENT_ITEM_SCHEMA);
2015    digest_field(&mut digest, stable_scope_id.as_str());
2016    digest_field(&mut digest, capability_id);
2017    digest_field(&mut digest, stable_subject_id.as_str());
2018    match target {
2019        AssessmentItemTarget::Subject => digest_field(&mut digest, "subject"),
2020        AssessmentItemTarget::QueryParameter(name) => {
2021            digest_field(&mut digest, "query_parameter");
2022            digest_field(&mut digest, name);
2023        },
2024        #[cfg(feature = "ssrf-oast-review")]
2025        AssessmentItemTarget::SsrfOastQuery(identity) => {
2026            digest_field(&mut digest, "ssrf_oast_query");
2027            digest_field(&mut digest, identity);
2028        },
2029        #[cfg(feature = "authorization-review")]
2030        AssessmentItemTarget::AuthorizationResource(identity) => {
2031            digest_field(&mut digest, "authorization_resource");
2032            digest_field(&mut digest, identity);
2033        },
2034        #[cfg(feature = "openapi-review")]
2035        AssessmentItemTarget::OpenApiDocument(identity) => {
2036            digest_field(&mut digest, "openapi_document");
2037            digest_field(&mut digest, identity);
2038        },
2039        #[cfg(feature = "rest-review")]
2040        AssessmentItemTarget::RestOperation(identity) => {
2041            digest_field(&mut digest, "rest_operation");
2042            digest_field(&mut digest, identity);
2043        },
2044    }
2045    format!("sha256:{:x}", digest.finalize())
2046}
2047
2048fn assessment_subject_fingerprint(
2049    stable_scope_id: &StableAssessmentScopeId,
2050    stable_subject_id: &StableAssessmentSubjectId,
2051) -> String {
2052    let mut digest = Sha256::new();
2053    digest.update(SUBJECT_IDENTITY_DOMAIN);
2054    digest_field(&mut digest, stable_scope_id.as_str());
2055    digest_field(&mut digest, stable_subject_id.as_str());
2056    format!("sha256:{:x}", digest.finalize())
2057}
2058
2059fn digest_field(digest: &mut Sha256, value: &str) {
2060    digest.update((value.len() as u64).to_be_bytes());
2061    digest.update(value.as_bytes());
2062}
2063
2064#[cfg(test)]
2065#[derive(Debug, Clone, Copy)]
2066struct ConfirmationProof {
2067    capability_policy: bool,
2068    action_matches: bool,
2069    hypothesis_claim_matches: bool,
2070    outcome_success: bool,
2071    transition_authorized: bool,
2072    hypothesis_write: bool,
2073    final_hypothesis_confirmed: bool,
2074    case_matches: bool,
2075    selected_verifier_matches: bool,
2076    evidence_nonempty: bool,
2077    evidence_resolved: bool,
2078    evidence_subject_matches: bool,
2079    evidence_case_matches: bool,
2080    receipt_case_matches: bool,
2081    receipt_stage_matches: bool,
2082    receipt_contributed: bool,
2083    receipt_evidence_matches: bool,
2084}
2085
2086#[cfg(test)]
2087impl ConfirmationProof {
2088    fn authorize(self) -> Result<(), AssessmentItemProjectionError> {
2089        let denied = if !self.capability_policy {
2090            Some(AssessmentConfirmationDenial::CapabilityPolicy)
2091        } else if !self.action_matches {
2092            Some(AssessmentConfirmationDenial::ActionMismatch)
2093        } else if !self.hypothesis_claim_matches {
2094            Some(AssessmentConfirmationDenial::HypothesisClaimMismatch)
2095        } else if !self.outcome_success {
2096            Some(AssessmentConfirmationDenial::OutcomeNotSuccess)
2097        } else if !self.transition_authorized {
2098            Some(AssessmentConfirmationDenial::KnowledgeOnly)
2099        } else if !self.hypothesis_write {
2100            Some(AssessmentConfirmationDenial::MissingHypothesisWrite)
2101        } else if !self.final_hypothesis_confirmed {
2102            Some(AssessmentConfirmationDenial::FinalHypothesisNotConfirmed)
2103        } else if !self.case_matches {
2104            Some(AssessmentConfirmationDenial::CaseMismatch)
2105        } else if !self.selected_verifier_matches {
2106            Some(AssessmentConfirmationDenial::SelectedVerifierMismatch)
2107        } else if !self.evidence_nonempty {
2108            Some(AssessmentConfirmationDenial::MissingEvidence)
2109        } else if !self.evidence_resolved {
2110            Some(AssessmentConfirmationDenial::EvidenceUnavailable)
2111        } else if !self.evidence_subject_matches {
2112            Some(AssessmentConfirmationDenial::EvidenceSubjectMismatch)
2113        } else if !self.evidence_case_matches {
2114            Some(AssessmentConfirmationDenial::EvidenceCaseMismatch)
2115        } else if !self.receipt_case_matches {
2116            Some(AssessmentConfirmationDenial::ReceiptCaseMismatch)
2117        } else if !self.receipt_stage_matches {
2118            Some(AssessmentConfirmationDenial::ReceiptStageMismatch)
2119        } else if !self.receipt_contributed {
2120            Some(AssessmentConfirmationDenial::ReceiptDidNotContribute)
2121        } else if !self.receipt_evidence_matches {
2122            Some(AssessmentConfirmationDenial::ReceiptEvidenceMismatch)
2123        } else {
2124            None
2125        };
2126        denied.map_or(Ok(()), |reason| {
2127            Err(AssessmentItemProjectionError::ConfirmationDenied(reason))
2128        })
2129    }
2130}
2131
2132#[cfg(test)]
2133struct ConfirmationExtraction<'a> {
2134    proof: ConfirmationProof,
2135    evidence_ids: &'a BTreeSet<EvidenceId>,
2136}
2137
2138#[cfg(test)]
2139fn extract_confirmation_proof<'a>(
2140    capability: &'static AssessmentCapabilityDescriptor,
2141    receipt: &DecisionEvidenceReceipt,
2142    decision: &'a DecisionOutcomeReport,
2143    knowledge: &KnowledgeBase,
2144) -> ConfirmationExtraction<'a> {
2145    let verification = decision.verification();
2146    let case = verification.case();
2147    let outcome = verification.outcome();
2148    let policy = capability.verifier_policy();
2149    let selected = verification
2150        .evaluations()
2151        .iter()
2152        .filter(|evaluation| evaluation.selected())
2153        .collect::<Vec<_>>();
2154    let selected_verifier_matches = policy.is_some_and(|policy| {
2155        selected.len() == 1
2156            && selected[0].rule_id() == policy.verifier_rule_id
2157            && selected[0].stage() == policy.stage
2158            && selected[0].action_matched()
2159            && selected[0].eligible()
2160            && selected[0].condition().evidence_ids() == outcome.evidence_ids()
2161            && outcome.verifier_rule_id() == Some(policy.verifier_rule_id)
2162            && outcome.stage() == policy.stage
2163            && verification.stage() == policy.stage
2164    });
2165
2166    let final_hypothesis = knowledge.hypothesis(outcome.hypothesis_id());
2167    let hypothesis_claim_matches = policy.is_some_and(|policy| {
2168        final_hypothesis.as_ref().is_some_and(|hypothesis| {
2169            hypothesis.subject() == case.subject()
2170                && predicate_matches(
2171                    hypothesis.predicate(),
2172                    policy.hypothesis_namespace,
2173                    policy.hypothesis_name,
2174                )
2175                && policy.hypothesis_value.matches(hypothesis.value())
2176        })
2177    });
2178    let final_hypothesis_confirmed = final_hypothesis
2179        .as_ref()
2180        .is_some_and(|hypothesis| hypothesis.state() == HypothesisState::Confirmed);
2181    let case_matches = outcome.case_id() == case.id()
2182        && outcome.subject() == case.subject()
2183        && outcome.action_id() == case.action_id()
2184        && outcome.hypothesis_id() == case.hypothesis_id();
2185    let action_matches = policy.is_some_and(|policy| {
2186        case.action_id() == policy.action_id && outcome.action_id() == policy.action_id
2187    });
2188    let (evidence_resolved, evidence_subject_matches, evidence_case_matches) =
2189        validate_correlated_evidence(knowledge, outcome.evidence_ids(), case.subject(), case.id());
2190    let receipt_case_matches = receipt.case() == case;
2191    let receipt_stage_matches = execution_stage_matches(receipt.stage(), outcome.stage());
2192    let receipt_contributed = selected.first().is_some_and(|evaluation| {
2193        receipt_contributed_evidence(
2194            receipt,
2195            outcome.stage(),
2196            outcome.evidence_ids(),
2197            evaluation.fresh_evidence_ids(),
2198        )
2199    });
2200    let receipt_evidence_matches = selected.first().is_some_and(|evaluation| {
2201        receipt_evidence_matches_knowledge(
2202            receipt,
2203            knowledge,
2204            outcome.stage(),
2205            outcome.evidence_ids(),
2206            evaluation.fresh_evidence_ids(),
2207        )
2208    });
2209
2210    ConfirmationExtraction {
2211        proof: ConfirmationProof {
2212            capability_policy: policy.is_some(),
2213            action_matches,
2214            hypothesis_claim_matches,
2215            outcome_success: is_confirmation_outcome(outcome.status()),
2216            transition_authorized: case.applies_hypothesis_transition(),
2217            hypothesis_write: decision.hypothesis_write().is_some(),
2218            final_hypothesis_confirmed,
2219            case_matches,
2220            selected_verifier_matches,
2221            evidence_nonempty: !outcome.evidence_ids().is_empty(),
2222            evidence_resolved,
2223            evidence_subject_matches,
2224            evidence_case_matches,
2225            receipt_case_matches,
2226            receipt_stage_matches,
2227            receipt_contributed,
2228            receipt_evidence_matches,
2229        },
2230        evidence_ids: outcome.evidence_ids(),
2231    }
2232}
2233
2234#[cfg(test)]
2235const fn is_confirmation_outcome(status: OutcomeStatus) -> bool {
2236    matches!(status, OutcomeStatus::Success)
2237}
2238
2239#[cfg(test)]
2240fn predicate_matches(predicate: &KnowledgePredicate, namespace: &str, name: &str) -> bool {
2241    predicate.namespace() == namespace && predicate.name() == name
2242}
2243
2244#[cfg(test)]
2245fn validate_correlated_evidence(
2246    knowledge: &KnowledgeBase,
2247    evidence_ids: &BTreeSet<EvidenceId>,
2248    subject: &termivar_core::EntityId,
2249    case_id: &str,
2250) -> (bool, bool, bool) {
2251    let mut resolved = true;
2252    let mut subject_matches = true;
2253    let mut case_matches = true;
2254    for evidence_id in evidence_ids {
2255        match knowledge.evidence(evidence_id) {
2256            Some(evidence) => {
2257                subject_matches &= evidence.subject() == subject;
2258                case_matches &= evidence.source().correlation_id() == Some(case_id);
2259            },
2260            None => {
2261                resolved = false;
2262                subject_matches = false;
2263                case_matches = false;
2264            },
2265        }
2266    }
2267    (resolved, subject_matches, case_matches)
2268}
2269
2270#[cfg(test)]
2271fn execution_stage_matches(stage: DecisionExecutionStage, verification: VerificationStage) -> bool {
2272    matches!(
2273        (stage, verification),
2274        (DecisionExecutionStage::Passive, VerificationStage::Passive)
2275            | (DecisionExecutionStage::Active, VerificationStage::Active)
2276    )
2277}
2278
2279#[cfg(test)]
2280fn receipt_contributed_evidence(
2281    receipt: &DecisionEvidenceReceipt,
2282    stage: VerificationStage,
2283    outcome_evidence: &BTreeSet<EvidenceId>,
2284    fresh_evidence: &BTreeSet<EvidenceId>,
2285) -> bool {
2286    let receipt_contains = |wanted: &EvidenceId| {
2287        receipt
2288            .evidence()
2289            .iter()
2290            .any(|evidence| evidence.id() == wanted)
2291    };
2292    match stage {
2293        VerificationStage::Passive => {
2294            !outcome_evidence.is_empty() && outcome_evidence.iter().all(&receipt_contains)
2295        },
2296        VerificationStage::Active => {
2297            !fresh_evidence.is_empty()
2298                && fresh_evidence.is_subset(outcome_evidence)
2299                && fresh_evidence.iter().all(receipt_contains)
2300        },
2301        _ => false,
2302    }
2303}
2304
2305#[cfg(test)]
2306fn receipt_evidence_matches_knowledge(
2307    receipt: &DecisionEvidenceReceipt,
2308    knowledge: &KnowledgeBase,
2309    stage: VerificationStage,
2310    outcome_evidence: &BTreeSet<EvidenceId>,
2311    fresh_evidence: &BTreeSet<EvidenceId>,
2312) -> bool {
2313    let required = match stage {
2314        VerificationStage::Passive => outcome_evidence,
2315        VerificationStage::Active => fresh_evidence,
2316        _ => return false,
2317    };
2318    !required.is_empty()
2319        && required.iter().all(|evidence_id| {
2320            let receipt_evidence = receipt
2321                .evidence()
2322                .iter()
2323                .find(|evidence| evidence.id() == evidence_id);
2324            let committed_evidence = knowledge.evidence(evidence_id);
2325            receipt_evidence.is_some_and(|receipt_evidence| {
2326                committed_evidence
2327                    .as_ref()
2328                    .is_some_and(|committed| committed == receipt_evidence)
2329            })
2330        })
2331}
2332
2333#[cfg(test)]
2334#[path = "assessment_item_tests.rs"]
2335mod tests;