Skip to main content

termivar_core/
predicates.rs

1//! Canonical predicate vocabulary shared by evidence producers and reasoners.
2//!
3//! The descriptors in this module compile into [`KnowledgePredicate`] and do
4//! not introduce a second serialized predicate format. Custom predicates and
5//! the open `http.header.*` family remain supported.
6
7use serde::{Deserialize, Deserializer, Serialize};
8use sha2::{Digest, Sha256};
9use std::{
10    fmt,
11    time::{SystemTime, UNIX_EPOCH},
12};
13use thiserror::Error;
14
15use crate::{
16    ConfidenceScore, EntityId, Evidence, EvidenceId, EvidenceKind, EvidenceSource, EvidenceValue,
17    KnowledgePredicate, KnowledgeRelation, ReasoningModelError, RelationId, RelationKind,
18};
19
20const MAX_OPAQUE_CONTEXT_BYTES: usize = 256;
21
22/// A validated static name that converts to the canonical predicate contract.
23///
24/// Descriptors deliberately do not implement Serde. Persisted definitions
25/// continue to use the existing `{ "namespace", "name" }`
26/// [`KnowledgePredicate`] representation.
27#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
28pub struct PredicateDescriptor {
29    namespace: &'static str,
30    name: &'static str,
31    dotted: &'static str,
32}
33
34impl PredicateDescriptor {
35    const fn new(namespace: &'static str, name: &'static str, dotted: &'static str) -> Self {
36        Self {
37            namespace,
38            name,
39            dotted,
40        }
41    }
42
43    /// Returns the predicate namespace.
44    pub const fn namespace(self) -> &'static str {
45        self.namespace
46    }
47
48    /// Returns the predicate name within its namespace.
49    pub const fn name(self) -> &'static str {
50        self.name
51    }
52
53    /// Returns the stable dotted identifier used in diagnostics.
54    pub const fn dotted(self) -> &'static str {
55        self.dotted
56    }
57
58    /// Converts this descriptor to the canonical owned predicate type.
59    pub fn into_knowledge(self) -> KnowledgePredicate {
60        KnowledgePredicate::new(self.namespace, self.name)
61            .expect("static predicate descriptors contain non-empty components")
62    }
63}
64
65impl From<PredicateDescriptor> for KnowledgePredicate {
66    fn from(value: PredicateDescriptor) -> Self {
67        value.into_knowledge()
68    }
69}
70
71/// Standard raw HTTP observations emitted by Termivar evidence producers.
72///
73/// This is an open vocabulary: [`Self::response_header`] supports custom
74/// normalized response header names in addition to the common constants.
75pub struct HttpEvidencePredicate;
76
77impl HttpEvidencePredicate {
78    /// HTTP request method.
79    pub const REQUEST_METHOD: PredicateDescriptor =
80        PredicateDescriptor::new("http.request", "method", "http.request.method");
81    /// Requested URL.
82    pub const REQUEST_URL: PredicateDescriptor =
83        PredicateDescriptor::new("http.request", "url", "http.request.url");
84    /// One bounded, non-empty URL path segment.
85    pub const REQUEST_PATH_SEGMENT: PredicateDescriptor =
86        PredicateDescriptor::new("http.request", "path-segment", "http.request.path-segment");
87    /// Numeric HTTP response status.
88    pub const RESPONSE_STATUS: PredicateDescriptor =
89        PredicateDescriptor::new("http.response", "status", "http.response.status");
90    /// Final URL after redirects.
91    pub const RESPONSE_FINAL_URL: PredicateDescriptor =
92        PredicateDescriptor::new("http.response", "final-url", "http.response.final-url");
93    /// Debug-formatted HTTP protocol version.
94    pub const RESPONSE_VERSION: PredicateDescriptor =
95        PredicateDescriptor::new("http.response", "version", "http.response.version");
96    /// Validated, lowercase media-type essence without parameters.
97    pub const RESPONSE_MEDIA_TYPE: PredicateDescriptor =
98        PredicateDescriptor::new("http.response", "media-type", "http.response.media-type");
99    /// Whether the validated media type uses JSON or a `+json` suffix.
100    pub const RESPONSE_MEDIA_TYPE_JSON_COMPATIBLE: PredicateDescriptor = PredicateDescriptor::new(
101        "http.response",
102        "media-type-json-compatible",
103        "http.response.media-type-json-compatible",
104    );
105    /// Number of response body bytes retained by the bounded collector.
106    pub const RESPONSE_BODY_BYTES_OBSERVED: PredicateDescriptor = PredicateDescriptor::new(
107        "http.response",
108        "body-bytes-observed",
109        "http.response.body-bytes-observed",
110    );
111    /// Whether the bounded response body was truncated.
112    pub const RESPONSE_BODY_TRUNCATED: PredicateDescriptor = PredicateDescriptor::new(
113        "http.response",
114        "body-truncated",
115        "http.response.body-truncated",
116    );
117    /// SHA-256 digest of the observed response body bytes.
118    pub const RESPONSE_BODY_SHA256: PredicateDescriptor =
119        PredicateDescriptor::new("http.response", "body-sha256", "http.response.body-sha256");
120    /// Optional bounded textual response body sample.
121    pub const RESPONSE_BODY_SAMPLE: PredicateDescriptor =
122        PredicateDescriptor::new("http.response", "body-sample", "http.response.body-sample");
123    /// Named HTML form-control names (`input`/`select`/`textarea` `name`
124    /// attributes) conservatively observed in the bounded response sample.
125    /// This predicate contains control *names* only; control values are never
126    /// copied into it. (The separate, host-authorized [`Self::RESPONSE_BODY_SAMPLE`]
127    /// still carries the original bounded HTML, which may include `value=`
128    /// attribute contents.) Presence indicates candidate input discovery, not
129    /// server acceptance, and never implies a complete set.
130    pub const RESPONSE_FORM_CONTROL_NAMES: PredicateDescriptor = PredicateDescriptor::new(
131        "http.response",
132        "form-control-names",
133        "http.response.form-control-names",
134    );
135    /// Time to first response byte in milliseconds.
136    pub const TIMING_TTFB_MS: PredicateDescriptor =
137        PredicateDescriptor::new("http.timing", "ttfb-ms", "http.timing.ttfb-ms");
138    /// Total request duration in milliseconds.
139    pub const TIMING_TOTAL_MS: PredicateDescriptor =
140        PredicateDescriptor::new("http.timing", "total-ms", "http.timing.total-ms");
141    /// Response cookie name. Cookie values are never represented here.
142    pub const COOKIE_NAME: PredicateDescriptor =
143        PredicateDescriptor::new("http.cookie", "name", "http.cookie.name");
144    /// Captured `Allow` response header.
145    pub const HEADER_ALLOW: PredicateDescriptor =
146        PredicateDescriptor::new("http.header", "allow", "http.header.allow");
147    /// Captured `Content-Type` response header.
148    pub const HEADER_CONTENT_TYPE: PredicateDescriptor =
149        PredicateDescriptor::new("http.header", "content-type", "http.header.content-type");
150    /// Captured `Server` response header.
151    pub const HEADER_SERVER: PredicateDescriptor =
152        PredicateDescriptor::new("http.header", "server", "http.header.server");
153    /// Captured `WWW-Authenticate` response header.
154    pub const HEADER_WWW_AUTHENTICATE: PredicateDescriptor = PredicateDescriptor::new(
155        "http.header",
156        "www-authenticate",
157        "http.header.www-authenticate",
158    );
159    /// Captured `X-Powered-By` response header.
160    pub const HEADER_X_POWERED_BY: PredicateDescriptor =
161        PredicateDescriptor::new("http.header", "x-powered-by", "http.header.x-powered-by");
162    /// Whether the response status directly indicated rate limiting.
163    pub const RATE_LIMIT_DETECTED: PredicateDescriptor =
164        PredicateDescriptor::new("http.rate-limit", "detected", "http.rate-limit.detected");
165    /// Whether the response advertised rate-limit metadata.
166    pub const RATE_LIMIT_ADVERTISED: PredicateDescriptor = PredicateDescriptor::new(
167        "http.rate-limit",
168        "advertised",
169        "http.rate-limit.advertised",
170    );
171    /// Normalized `Retry-After` value.
172    pub const RATE_LIMIT_RETRY_AFTER: PredicateDescriptor = PredicateDescriptor::new(
173        "http.rate-limit",
174        "retry-after",
175        "http.rate-limit.retry-after",
176    );
177    /// Normalized rate-limit capacity.
178    pub const RATE_LIMIT_LIMIT: PredicateDescriptor =
179        PredicateDescriptor::new("http.rate-limit", "limit", "http.rate-limit.limit");
180    /// Normalized remaining rate-limit capacity.
181    pub const RATE_LIMIT_REMAINING: PredicateDescriptor =
182        PredicateDescriptor::new("http.rate-limit", "remaining", "http.rate-limit.remaining");
183    /// Normalized rate-limit reset value.
184    pub const RATE_LIMIT_RESET: PredicateDescriptor =
185        PredicateDescriptor::new("http.rate-limit", "reset", "http.rate-limit.reset");
186
187    /// Creates an open-family predicate for a validated, normalized header.
188    ///
189    /// HTTP producers remain responsible for header syntax validation and
190    /// lowercase normalization before calling this method.
191    pub fn response_header(
192        normalized_name: impl Into<String>,
193    ) -> Result<KnowledgePredicate, ReasoningModelError> {
194        KnowledgePredicate::new("http.header", normalized_name)
195    }
196
197    /// Returns every fixed descriptor in stable declaration order.
198    pub const fn fixed() -> &'static [PredicateDescriptor] {
199        &[
200            Self::REQUEST_METHOD,
201            Self::REQUEST_URL,
202            Self::REQUEST_PATH_SEGMENT,
203            Self::RESPONSE_STATUS,
204            Self::RESPONSE_FINAL_URL,
205            Self::RESPONSE_VERSION,
206            Self::RESPONSE_MEDIA_TYPE,
207            Self::RESPONSE_MEDIA_TYPE_JSON_COMPATIBLE,
208            Self::RESPONSE_BODY_BYTES_OBSERVED,
209            Self::RESPONSE_BODY_TRUNCATED,
210            Self::RESPONSE_BODY_SHA256,
211            Self::RESPONSE_BODY_SAMPLE,
212            Self::RESPONSE_FORM_CONTROL_NAMES,
213            Self::TIMING_TTFB_MS,
214            Self::TIMING_TOTAL_MS,
215            Self::COOKIE_NAME,
216            Self::HEADER_ALLOW,
217            Self::HEADER_CONTENT_TYPE,
218            Self::HEADER_SERVER,
219            Self::HEADER_WWW_AUTHENTICATE,
220            Self::HEADER_X_POWERED_BY,
221            Self::RATE_LIMIT_DETECTED,
222            Self::RATE_LIMIT_ADVERTISED,
223            Self::RATE_LIMIT_RETRY_AFTER,
224            Self::RATE_LIMIT_LIMIT,
225            Self::RATE_LIMIT_REMAINING,
226            Self::RATE_LIMIT_RESET,
227        ]
228    }
229}
230
231/// Names-only observations produced by bounded exact-origin HTML discovery.
232///
233/// Route and form predicates carry canonical, query-free URLs. Query and
234/// control predicates carry names only. The vocabulary deliberately has no
235/// predicate for a query value, form value, response body, or authorization
236/// material.
237pub struct WebDiscoveryEvidencePredicate;
238
239impl WebDiscoveryEvidencePredicate {
240    /// A complete eligible HTML document was projected into typed discovery
241    /// evidence.
242    pub const DOCUMENT_PROJECTED: PredicateDescriptor = PredicateDescriptor::new(
243        "web.discovery",
244        "document-projected",
245        "web.discovery.document-projected",
246    );
247    /// A committed GET response did not reach observed transport EOF and
248    /// therefore cannot support complete-document discovery. HEAD is exempt.
249    pub const DOCUMENT_BODY_INCOMPLETE: PredicateDescriptor = PredicateDescriptor::new(
250        "web.discovery",
251        "document-body-incomplete",
252        "web.discovery.document-body-incomplete",
253    );
254    /// HTTP 206 completed its bounded transfer but represents only a partial
255    /// document and therefore was not projected as a complete HTML resource.
256    pub const DOCUMENT_PARTIAL_REPRESENTATION: PredicateDescriptor = PredicateDescriptor::new(
257        "web.discovery",
258        "document-partial-representation",
259        "web.discovery.document-partial-representation",
260    );
261    /// An otherwise eligible complete HTML body was not valid UTF-8 and was not
262    /// parsed lossily.
263    pub const DOCUMENT_INVALID_UTF8: PredicateDescriptor = PredicateDescriptor::new(
264        "web.discovery",
265        "document-invalid-utf8",
266        "web.discovery.document-invalid-utf8",
267    );
268    /// Valid route references exceeded the configured per-document ceiling.
269    pub const DOCUMENT_ROUTE_LIMIT_REACHED: PredicateDescriptor = PredicateDescriptor::new(
270        "web.discovery",
271        "document-route-limit-reached",
272        "web.discovery.document-route-limit-reached",
273    );
274    /// Valid forms exceeded the configured assessment ceiling.
275    pub const DOCUMENT_FORM_LIMIT_REACHED: PredicateDescriptor = PredicateDescriptor::new(
276        "web.discovery",
277        "document-form-limit-reached",
278        "web.discovery.document-form-limit-reached",
279    );
280    /// A retained form contained more names than the per-form control ceiling.
281    pub const DOCUMENT_CONTROL_LIMIT_REACHED: PredicateDescriptor = PredicateDescriptor::new(
282        "web.discovery",
283        "document-control-limit-reached",
284        "web.discovery.document-control-limit-reached",
285    );
286    /// A route or form action contained more distinct query names than the
287    /// configured per-reference ceiling.
288    pub const DOCUMENT_QUERY_NAME_LIMIT_REACHED: PredicateDescriptor = PredicateDescriptor::new(
289        "web.discovery",
290        "document-query-name-limit-reached",
291        "web.discovery.document-query-name-limit-reached",
292    );
293    /// A reference was rejected before retention because its URL representation
294    /// exceeded the configured byte ceiling.
295    pub const DOCUMENT_URL_BYTE_LIMIT_REACHED: PredicateDescriptor = PredicateDescriptor::new(
296        "web.discovery",
297        "document-url-byte-limit-reached",
298        "web.discovery.document-url-byte-limit-reached",
299    );
300    /// The assessment-wide canonical subject ceiling rejected at least one
301    /// otherwise admissible route.
302    pub const ASSESSMENT_SUBJECT_LIMIT_REACHED: PredicateDescriptor = PredicateDescriptor::new(
303        "web.discovery",
304        "assessment-subject-limit-reached",
305        "web.discovery.assessment-subject-limit-reached",
306    );
307    /// The assessment depth boundary rejected at least one otherwise
308    /// admissible child route or form action.
309    pub const ASSESSMENT_DEPTH_LIMIT_REACHED: PredicateDescriptor = PredicateDescriptor::new(
310        "web.discovery",
311        "assessment-depth-limit-reached",
312        "web.discovery.assessment-depth-limit-reached",
313    );
314    /// The assessment-wide unique canonical URL byte envelope rejected at
315    /// least one otherwise admissible route or form action.
316    pub const ASSESSMENT_RETAINED_URL_BYTE_LIMIT_REACHED: PredicateDescriptor =
317        PredicateDescriptor::new(
318            "web.discovery",
319            "assessment-retained-url-byte-limit-reached",
320            "web.discovery.assessment-retained-url-byte-limit-reached",
321        );
322    /// Number of valid HTTP(S) document references rejected because they did
323    /// not match the authorized exact origin.
324    pub const DOCUMENT_OUTSIDE_ORIGIN_REFERENCE_COUNT: PredicateDescriptor =
325        PredicateDescriptor::new(
326            "web.discovery",
327            "document-outside-origin-reference-count",
328            "web.discovery.document-outside-origin-reference-count",
329        );
330    /// One canonical query-free route eligible for a GET observation.
331    pub const GET_ROUTE: PredicateDescriptor =
332        PredicateDescriptor::new("web.discovery", "get-route", "web.discovery.get-route");
333    /// One canonical query-free resource eligible for a HEAD observation.
334    pub const HEAD_ROUTE: PredicateDescriptor =
335        PredicateDescriptor::new("web.discovery", "head-route", "web.discovery.head-route");
336    /// Sorted, de-duplicated query parameter names carried by one route
337    /// reference.
338    pub const ROUTE_QUERY_PARAMETER_NAMES: PredicateDescriptor = PredicateDescriptor::new(
339        "web.discovery",
340        "route-query-parameter-names",
341        "web.discovery.route-query-parameter-names",
342    );
343    /// One canonical query-free GET form action.
344    pub const GET_FORM_ACTION: PredicateDescriptor = PredicateDescriptor::new(
345        "web.discovery",
346        "get-form-action",
347        "web.discovery.get-form-action",
348    );
349    /// One canonical query-free POST form action. Discovery records it but does
350    /// not dispatch it.
351    pub const POST_FORM_ACTION: PredicateDescriptor = PredicateDescriptor::new(
352        "web.discovery",
353        "post-form-action",
354        "web.discovery.post-form-action",
355    );
356    /// One canonical query-free dialog form action. Discovery records it but
357    /// does not dispatch it.
358    pub const DIALOG_FORM_ACTION: PredicateDescriptor = PredicateDescriptor::new(
359        "web.discovery",
360        "dialog-form-action",
361        "web.discovery.dialog-form-action",
362    );
363    /// Sorted, de-duplicated query parameter names carried by one form action.
364    pub const FORM_QUERY_PARAMETER_NAMES: PredicateDescriptor = PredicateDescriptor::new(
365        "web.discovery",
366        "form-query-parameter-names",
367        "web.discovery.form-query-parameter-names",
368    );
369    /// Sorted, de-duplicated HTML form-control names. Values are never present.
370    pub const FORM_CONTROL_NAMES: PredicateDescriptor = PredicateDescriptor::new(
371        "web.discovery",
372        "form-control-names",
373        "web.discovery.form-control-names",
374    );
375
376    /// Returns every fixed discovery descriptor in stable declaration order.
377    pub const fn fixed() -> &'static [PredicateDescriptor] {
378        &[
379            Self::DOCUMENT_PROJECTED,
380            Self::DOCUMENT_BODY_INCOMPLETE,
381            Self::DOCUMENT_PARTIAL_REPRESENTATION,
382            Self::DOCUMENT_INVALID_UTF8,
383            Self::DOCUMENT_ROUTE_LIMIT_REACHED,
384            Self::DOCUMENT_FORM_LIMIT_REACHED,
385            Self::DOCUMENT_CONTROL_LIMIT_REACHED,
386            Self::DOCUMENT_QUERY_NAME_LIMIT_REACHED,
387            Self::DOCUMENT_URL_BYTE_LIMIT_REACHED,
388            Self::ASSESSMENT_SUBJECT_LIMIT_REACHED,
389            Self::ASSESSMENT_DEPTH_LIMIT_REACHED,
390            Self::ASSESSMENT_RETAINED_URL_BYTE_LIMIT_REACHED,
391            Self::DOCUMENT_OUTSIDE_ORIGIN_REFERENCE_COUNT,
392            Self::GET_ROUTE,
393            Self::HEAD_ROUTE,
394            Self::ROUTE_QUERY_PARAMETER_NAMES,
395            Self::GET_FORM_ACTION,
396            Self::POST_FORM_ACTION,
397            Self::DIALOG_FORM_ACTION,
398            Self::FORM_QUERY_PARAMETER_NAMES,
399            Self::FORM_CONTROL_NAMES,
400        ]
401    }
402}
403
404/// Standard conclusions produced by web fingerprint reasoning.
405pub struct WebKnowledgePredicate;
406
407impl WebKnowledgePredicate {
408    /// Disclosed or inferred web server product.
409    pub const TECHNOLOGY_WEB_SERVER: PredicateDescriptor =
410        PredicateDescriptor::new("technology", "web-server", "technology.web-server");
411    /// Disclosed or inferred implementation language.
412    pub const TECHNOLOGY_LANGUAGE: PredicateDescriptor =
413        PredicateDescriptor::new("technology", "language", "technology.language");
414    /// Disclosed or inferred server-side framework.
415    pub const TECHNOLOGY_FRAMEWORK: PredicateDescriptor =
416        PredicateDescriptor::new("technology", "framework", "technology.framework");
417    /// Disclosed or inferred UI framework.
418    pub const TECHNOLOGY_UI_FRAMEWORK: PredicateDescriptor =
419        PredicateDescriptor::new("technology", "ui-framework", "technology.ui-framework");
420    /// Disclosed or inferred authentication mechanism.
421    pub const AUTHENTICATION_MECHANISM: PredicateDescriptor =
422        PredicateDescriptor::new("authentication", "mechanism", "authentication.mechanism");
423    /// A form-control naming convention observed in a bounded response sample.
424    ///
425    /// The value is a normalized convention label (e.g. `csrf-token`,
426    /// `method-override`), never a framework identity. It records that an
427    /// observed control name matches a convention *compatible with* a framework
428    /// — it does not assert the framework generated the form, that the parameter
429    /// is accepted, or that the observed control set is complete.
430    pub const FORM_CONVENTION: PredicateDescriptor =
431        PredicateDescriptor::new("web.form", "convention", "web.form.convention");
432}
433
434/// Raw, atomic API comparison observations.
435pub struct ApiEvidencePredicate;
436
437impl ApiEvidencePredicate {
438    /// JSON UI/API comparison found a difference.
439    pub const JSON_UI_API_DIFFERENCE: PredicateDescriptor = PredicateDescriptor::new(
440        "api.visibility.json.ui-api",
441        "difference",
442        "api.visibility.json.ui-api.difference",
443    );
444    /// JSON UI/API comparison found equivalent visibility.
445    pub const JSON_UI_API_EQUIVALENT: PredicateDescriptor = PredicateDescriptor::new(
446        "api.visibility.json.ui-api",
447        "equivalent",
448        "api.visibility.json.ui-api.equivalent",
449    );
450    /// JSON authorization-context comparison found a difference.
451    pub const JSON_AUTHORIZATION_CONTEXT_DIFFERENCE: PredicateDescriptor = PredicateDescriptor::new(
452        "api.visibility.json.authorization-context",
453        "difference",
454        "api.visibility.json.authorization-context.difference",
455    );
456    /// JSON authorization-context comparison found equivalent visibility.
457    pub const JSON_AUTHORIZATION_CONTEXT_EQUIVALENT: PredicateDescriptor = PredicateDescriptor::new(
458        "api.visibility.json.authorization-context",
459        "equivalent",
460        "api.visibility.json.authorization-context.equivalent",
461    );
462    /// GraphQL UI/API comparison found a difference.
463    pub const GRAPHQL_UI_API_DIFFERENCE: PredicateDescriptor = PredicateDescriptor::new(
464        "api.visibility.graphql.ui-api",
465        "difference",
466        "api.visibility.graphql.ui-api.difference",
467    );
468    /// GraphQL UI/API comparison found equivalent visibility.
469    pub const GRAPHQL_UI_API_EQUIVALENT: PredicateDescriptor = PredicateDescriptor::new(
470        "api.visibility.graphql.ui-api",
471        "equivalent",
472        "api.visibility.graphql.ui-api.equivalent",
473    );
474    /// GraphQL authorization-context comparison found a difference.
475    pub const GRAPHQL_AUTHORIZATION_CONTEXT_DIFFERENCE: PredicateDescriptor =
476        PredicateDescriptor::new(
477            "api.visibility.graphql.authorization-context",
478            "difference",
479            "api.visibility.graphql.authorization-context.difference",
480        );
481    /// GraphQL authorization-context comparison found equivalent visibility.
482    pub const GRAPHQL_AUTHORIZATION_CONTEXT_EQUIVALENT: PredicateDescriptor =
483        PredicateDescriptor::new(
484            "api.visibility.graphql.authorization-context",
485            "equivalent",
486            "api.visibility.graphql.authorization-context.equivalent",
487        );
488
489    /// Selects the one predicate that completely classifies a paired result.
490    pub const fn visibility(
491        surface: ApiSurfaceKind,
492        pair: ApiVisibilityPairKind,
493        result: ApiVisibilityResult,
494    ) -> PredicateDescriptor {
495        match (surface, pair, result) {
496            (
497                ApiSurfaceKind::JsonHttp,
498                ApiVisibilityPairKind::UiApi,
499                ApiVisibilityResult::Different,
500            ) => Self::JSON_UI_API_DIFFERENCE,
501            (
502                ApiSurfaceKind::JsonHttp,
503                ApiVisibilityPairKind::UiApi,
504                ApiVisibilityResult::Equivalent,
505            ) => Self::JSON_UI_API_EQUIVALENT,
506            (
507                ApiSurfaceKind::JsonHttp,
508                ApiVisibilityPairKind::AuthorizationContext,
509                ApiVisibilityResult::Different,
510            ) => Self::JSON_AUTHORIZATION_CONTEXT_DIFFERENCE,
511            (
512                ApiSurfaceKind::JsonHttp,
513                ApiVisibilityPairKind::AuthorizationContext,
514                ApiVisibilityResult::Equivalent,
515            ) => Self::JSON_AUTHORIZATION_CONTEXT_EQUIVALENT,
516            (
517                ApiSurfaceKind::GraphQl,
518                ApiVisibilityPairKind::UiApi,
519                ApiVisibilityResult::Different,
520            ) => Self::GRAPHQL_UI_API_DIFFERENCE,
521            (
522                ApiSurfaceKind::GraphQl,
523                ApiVisibilityPairKind::UiApi,
524                ApiVisibilityResult::Equivalent,
525            ) => Self::GRAPHQL_UI_API_EQUIVALENT,
526            (
527                ApiSurfaceKind::GraphQl,
528                ApiVisibilityPairKind::AuthorizationContext,
529                ApiVisibilityResult::Different,
530            ) => Self::GRAPHQL_AUTHORIZATION_CONTEXT_DIFFERENCE,
531            (
532                ApiSurfaceKind::GraphQl,
533                ApiVisibilityPairKind::AuthorizationContext,
534                ApiVisibilityResult::Equivalent,
535            ) => Self::GRAPHQL_AUTHORIZATION_CONTEXT_EQUIVALENT,
536        }
537    }
538}
539
540/// Standard hypotheses produced by API reasoning profiles.
541pub struct ApiKnowledgePredicate;
542
543impl ApiKnowledgePredicate {
544    /// Observed response representation.
545    pub const RESPONSE_FORMAT: PredicateDescriptor =
546        PredicateDescriptor::new("api", "response-format", "api.response-format");
547    /// Inferred API surface kind.
548    pub const SURFACE_KIND: PredicateDescriptor =
549        PredicateDescriptor::new("api.surface", "kind", "api.surface.kind");
550    /// Paired visibility boundary that deserves review.
551    pub const VISIBILITY_BOUNDARY: PredicateDescriptor =
552        PredicateDescriptor::new("api.visibility", "boundary", "api.visibility.boundary");
553}
554
555/// Response representations recognized by the standard API vocabulary.
556#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
557#[serde(rename_all = "kebab-case")]
558#[non_exhaustive]
559pub enum ApiResponseFormat {
560    /// JavaScript Object Notation.
561    Json,
562}
563
564impl ApiResponseFormat {
565    /// Returns the stable ontology value.
566    pub const fn as_str(self) -> &'static str {
567        match self {
568            Self::Json => "json",
569        }
570    }
571}
572
573impl From<ApiResponseFormat> for EvidenceValue {
574    fn from(value: ApiResponseFormat) -> Self {
575        Self::Text(value.as_str().to_owned())
576    }
577}
578
579/// API surface families represented by paired observations and hypotheses.
580#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
581#[serde(rename_all = "kebab-case")]
582#[non_exhaustive]
583pub enum ApiSurfaceKind {
584    /// Conventional HTTP API with JSON representations.
585    JsonHttp,
586    /// GraphQL API surface.
587    #[serde(rename = "graphql")]
588    GraphQl,
589}
590
591impl ApiSurfaceKind {
592    /// Returns the stable ontology value.
593    pub const fn as_str(self) -> &'static str {
594        match self {
595            Self::JsonHttp => "json-http-api",
596            Self::GraphQl => "graphql-api",
597        }
598    }
599}
600
601impl From<ApiSurfaceKind> for EvidenceValue {
602    fn from(value: ApiSurfaceKind) -> Self {
603        Self::Text(value.as_str().to_owned())
604    }
605}
606
607/// The two views compared by one atomic visibility observation.
608#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
609#[serde(rename_all = "kebab-case")]
610#[non_exhaustive]
611pub enum ApiVisibilityPairKind {
612    /// User-interface behavior compared with its backing API behavior.
613    UiApi,
614    /// The same logical resource compared across authorization contexts.
615    AuthorizationContext,
616}
617
618impl ApiVisibilityPairKind {
619    /// Returns the stable wire value.
620    pub const fn as_str(self) -> &'static str {
621        match self {
622            Self::UiApi => "ui-api",
623            Self::AuthorizationContext => "authorization-context",
624        }
625    }
626}
627
628/// Outcome of an already paired visibility comparison.
629#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
630#[serde(rename_all = "kebab-case")]
631#[non_exhaustive]
632pub enum ApiVisibilityResult {
633    /// The candidate view differed from the baseline view.
634    Different,
635    /// The compared views were equivalent for the selected dimension.
636    Equivalent,
637}
638
639impl ApiVisibilityResult {
640    /// Returns the stable wire value.
641    pub const fn as_str(self) -> &'static str {
642        match self {
643            Self::Different => "different",
644            Self::Equivalent => "equivalent",
645        }
646    }
647}
648
649/// Dimension measured by a paired visibility comparison.
650#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
651#[serde(rename_all = "kebab-case")]
652#[non_exhaustive]
653pub enum ApiVisibilityDimension {
654    /// Logical resources or records.
655    Resources,
656    /// Object fields or properties.
657    Fields,
658    /// HTTP or protocol result status.
659    Status,
660}
661
662impl ApiVisibilityDimension {
663    /// Returns the stable evidence value.
664    pub const fn as_str(self) -> &'static str {
665        match self {
666            Self::Resources => "resources",
667            Self::Fields => "fields",
668            Self::Status => "status",
669        }
670    }
671
672    /// Returns every currently standardized comparison dimension.
673    pub const fn all() -> [Self; 3] {
674        [Self::Resources, Self::Fields, Self::Status]
675    }
676}
677
678impl From<ApiVisibilityDimension> for EvidenceValue {
679    fn from(value: ApiVisibilityDimension) -> Self {
680        Self::Text(value.as_str().to_owned())
681    }
682}
683
684/// Visibility-boundary hypotheses emitted by the standard API profile.
685#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
686#[serde(rename_all = "kebab-case")]
687#[non_exhaustive]
688pub enum ApiVisibilityBoundaryKind {
689    /// UI behavior and backing API behavior expose different views.
690    UiApi,
691    /// Two authorization contexts expose different views.
692    AuthorizationContext,
693}
694
695impl ApiVisibilityBoundaryKind {
696    /// Returns the stable ontology value.
697    pub const fn as_str(self) -> &'static str {
698        match self {
699            Self::UiApi => "ui-api-visibility-boundary",
700            Self::AuthorizationContext => "authorization-context-visibility-boundary",
701        }
702    }
703}
704
705impl From<ApiVisibilityBoundaryKind> for EvidenceValue {
706    fn from(value: ApiVisibilityBoundaryKind) -> Self {
707        Self::Text(value.as_str().to_owned())
708    }
709}
710
711/// Validation failures for typed API comparison observations.
712#[derive(Debug, Clone, PartialEq, Eq, Error)]
713#[non_exhaustive]
714pub enum ApiVocabularyError {
715    /// A core reasoning identifier was invalid.
716    #[error(transparent)]
717    Reasoning(#[from] ReasoningModelError),
718
719    /// An opaque context identifier was empty.
720    #[error("{field} must not be empty")]
721    EmptyContext { field: &'static str },
722
723    /// An opaque context identifier exceeded the bounded contract.
724    #[error("{field} exceeds the {maximum}-byte limit")]
725    ContextTooLong {
726        /// Invalid field name.
727        field: &'static str,
728        /// Inclusive maximum length.
729        maximum: usize,
730    },
731
732    /// A comparison attempted to use the same baseline and candidate view.
733    #[error("baseline and candidate context ids must identify different views")]
734    IdenticalContexts,
735
736    /// A paired observation cannot claim zero source reliability.
737    #[error("API visibility comparison reliability must be greater than zero")]
738    ZeroReliability,
739}
740
741/// Validated, bounded identifier for one host-owned API comparison.
742///
743/// The value remains serializable for compatibility with the established API
744/// visibility wire contract, but its [`std::fmt::Debug`] representation is always
745/// redacted. It must be an opaque, non-secret handle.
746#[repr(transparent)]
747#[derive(Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize)]
748#[serde(transparent)]
749pub struct ComparisonId(String);
750
751impl ComparisonId {
752    /// Validates and constructs an opaque comparison identifier.
753    pub fn new(value: impl Into<String>) -> Result<Self, ApiVocabularyError> {
754        opaque_context(value, "comparison id").map(Self)
755    }
756
757    /// Returns the validated string value.
758    pub fn as_str(&self) -> &str {
759        &self.0
760    }
761
762    /// Consumes the identifier and returns its string value.
763    pub fn into_string(self) -> String {
764        self.0
765    }
766}
767
768impl fmt::Debug for ComparisonId {
769    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
770        formatter
771            .debug_tuple("ComparisonId")
772            .field(&"<redacted>")
773            .finish()
774    }
775}
776
777impl AsRef<str> for ComparisonId {
778    fn as_ref(&self) -> &str {
779        self.as_str()
780    }
781}
782
783impl<'de> Deserialize<'de> for ComparisonId {
784    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
785    where
786        D: Deserializer<'de>,
787    {
788        Self::new(String::deserialize(deserializer)?).map_err(serde::de::Error::custom)
789    }
790}
791
792/// Validated, bounded identifier for one API observation context.
793///
794/// Context identifiers are deliberately opaque. Serialization preserves the
795/// established string wire shape, while [`std::fmt::Debug`] never exposes the value.
796#[repr(transparent)]
797#[derive(Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize)]
798#[serde(transparent)]
799pub struct OpaqueContextId(String);
800
801impl OpaqueContextId {
802    /// Validates and constructs an opaque context identifier.
803    pub fn new(value: impl Into<String>) -> Result<Self, ApiVocabularyError> {
804        Self::new_for_field(value, "context id")
805    }
806
807    fn new_for_field(
808        value: impl Into<String>,
809        field: &'static str,
810    ) -> Result<Self, ApiVocabularyError> {
811        opaque_context(value, field).map(Self)
812    }
813
814    /// Returns the validated string value.
815    pub fn as_str(&self) -> &str {
816        &self.0
817    }
818
819    /// Consumes the identifier and returns its string value.
820    pub fn into_string(self) -> String {
821        self.0
822    }
823}
824
825impl fmt::Debug for OpaqueContextId {
826    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
827        formatter
828            .debug_tuple("OpaqueContextId")
829            .field(&"<redacted>")
830            .finish()
831    }
832}
833
834impl AsRef<str> for OpaqueContextId {
835    fn as_ref(&self) -> &str {
836        self.as_str()
837    }
838}
839
840impl<'de> Deserialize<'de> for OpaqueContextId {
841    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
842    where
843        D: Deserializer<'de>,
844    {
845        Self::new(String::deserialize(deserializer)?).map_err(serde::de::Error::custom)
846    }
847}
848
849/// Validated, bounded identifier for a host-declared logical resource.
850///
851/// The identifier is serialized as its original string for wire compatibility
852/// and redacted from [`std::fmt::Debug`] output to reduce accidental resource disclosure.
853#[repr(transparent)]
854#[derive(Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize)]
855#[serde(transparent)]
856pub struct ResourceScopeId(String);
857
858impl ResourceScopeId {
859    /// Validates and constructs an opaque resource-scope identifier.
860    pub fn new(value: impl Into<String>) -> Result<Self, ApiVocabularyError> {
861        opaque_context(value, "resource scope id").map(Self)
862    }
863
864    /// Returns the validated string value.
865    pub fn as_str(&self) -> &str {
866        &self.0
867    }
868
869    /// Consumes the identifier and returns its string value.
870    pub fn into_string(self) -> String {
871        self.0
872    }
873}
874
875impl fmt::Debug for ResourceScopeId {
876    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
877        formatter
878            .debug_tuple("ResourceScopeId")
879            .field(&"<redacted>")
880            .finish()
881    }
882}
883
884impl AsRef<str> for ResourceScopeId {
885    fn as_ref(&self) -> &str {
886        self.as_str()
887    }
888}
889
890impl<'de> Deserialize<'de> for ResourceScopeId {
891    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
892    where
893        D: Deserializer<'de>,
894    {
895        Self::new(String::deserialize(deserializer)?).map_err(serde::de::Error::custom)
896    }
897}
898
899/// One atomic API comparison observation plus its resource-scope graph edge.
900///
901/// The evidence remains scoped to a pseudonymous comparison subject so rule
902/// evaluation cannot merge principals accidentally. The relation makes that
903/// subject discoverable from the host-provided resource entity without
904/// putting context handles into the evidence value.
905#[derive(Debug, Clone, PartialEq, Eq)]
906pub struct ApiVisibilityObservation {
907    evidence: Evidence,
908    resource_scope: EntityId,
909    scope_relation: KnowledgeRelation,
910}
911
912impl ApiVisibilityObservation {
913    /// Returns the canonical paired-comparison evidence.
914    pub fn evidence(&self) -> &Evidence {
915        &self.evidence
916    }
917
918    /// Returns the opaque resource entity compared by the host.
919    pub fn resource_scope(&self) -> &EntityId {
920        &self.resource_scope
921    }
922
923    /// Returns the evidence-backed comparison-to-resource edge.
924    pub fn scope_relation(&self) -> &KnowledgeRelation {
925        &self.scope_relation
926    }
927
928    /// Splits the bundle into records for atomic knowledge-base insertion.
929    pub fn into_parts(self) -> (Evidence, KnowledgeRelation) {
930        (self.evidence, self.scope_relation)
931    }
932}
933
934/// One host-paired API visibility comparison.
935///
936/// This contract is intentionally atomic. The host must compare the same
937/// logical resource under the declared views before constructing it; the rule
938/// engine never combines independent UI, API, or principal observations.
939/// Context and scope identifiers must be opaque, non-secret handles. Raw
940/// credentials, tokens, URLs, response values, and resource names do not
941/// belong in this contract or its resulting evidence.
942///
943/// # Examples
944///
945/// ```rust
946/// use termivar_core::{
947///     ApiSurfaceKind, ApiVisibilityComparison, ApiVisibilityDimension,
948///     ApiVisibilityPairKind, ApiVisibilityResult, ConfidenceScore,
949/// };
950///
951/// let comparison = ApiVisibilityComparison::new(
952///     "comparison-17",
953///     ApiSurfaceKind::JsonHttp,
954///     ApiVisibilityPairKind::AuthorizationContext,
955///     ApiVisibilityResult::Different,
956///     ApiVisibilityDimension::Fields,
957///     "anonymous-context",
958///     "member-context",
959///     "account-resource",
960/// )?;
961/// let observation = comparison.to_observation("host.api-comparator", ConfidenceScore::MAX)?;
962/// let evidence = observation.evidence();
963///
964/// assert!(evidence.subject().as_str().starts_with("api-comparison:"));
965/// assert_eq!(evidence.source().correlation_id(), Some(evidence.subject().as_str()));
966/// assert_eq!(observation.scope_relation().to(), observation.resource_scope());
967/// # Ok::<(), Box<dyn std::error::Error>>(())
968/// ```
969#[derive(Clone, PartialEq, Eq, Serialize)]
970pub struct ApiVisibilityComparison {
971    comparison_id: ComparisonId,
972    surface: ApiSurfaceKind,
973    pair: ApiVisibilityPairKind,
974    result: ApiVisibilityResult,
975    dimension: ApiVisibilityDimension,
976    baseline_context_id: OpaqueContextId,
977    candidate_context_id: OpaqueContextId,
978    resource_scope_id: ResourceScopeId,
979    observed_at_ms: u64,
980}
981
982impl fmt::Debug for ApiVisibilityComparison {
983    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
984        formatter
985            .debug_struct("ApiVisibilityComparison")
986            .field("comparison_id", &"<redacted>")
987            .field("surface", &self.surface)
988            .field("pair", &self.pair)
989            .field("result", &self.result)
990            .field("dimension", &self.dimension)
991            .field("baseline_context_id", &"<redacted>")
992            .field("candidate_context_id", &"<redacted>")
993            .field("resource_scope_id", &"<redacted>")
994            .field("observed_at_ms", &self.observed_at_ms)
995            .finish()
996    }
997}
998
999impl ApiVisibilityComparison {
1000    /// Creates one validated, already-paired observation.
1001    #[allow(clippy::too_many_arguments)]
1002    pub fn new(
1003        comparison_id: impl Into<String>,
1004        surface: ApiSurfaceKind,
1005        pair: ApiVisibilityPairKind,
1006        result: ApiVisibilityResult,
1007        dimension: ApiVisibilityDimension,
1008        baseline_context_id: impl Into<String>,
1009        candidate_context_id: impl Into<String>,
1010        resource_scope_id: impl Into<String>,
1011    ) -> Result<Self, ApiVocabularyError> {
1012        let comparison_id = ComparisonId::new(comparison_id)?;
1013        let baseline_context_id =
1014            OpaqueContextId::new_for_field(baseline_context_id, "baseline context id")?;
1015        let candidate_context_id =
1016            OpaqueContextId::new_for_field(candidate_context_id, "candidate context id")?;
1017        let resource_scope_id = ResourceScopeId::new(resource_scope_id)?;
1018        Self::new_typed(
1019            comparison_id,
1020            surface,
1021            pair,
1022            result,
1023            dimension,
1024            baseline_context_id,
1025            candidate_context_id,
1026            resource_scope_id,
1027        )
1028    }
1029
1030    /// Creates one already-validated paired observation from typed identifiers.
1031    ///
1032    /// The baseline and candidate remain checked here because individually
1033    /// valid context identifiers must still represent distinct views.
1034    #[allow(clippy::too_many_arguments)]
1035    pub fn new_typed(
1036        comparison_id: ComparisonId,
1037        surface: ApiSurfaceKind,
1038        pair: ApiVisibilityPairKind,
1039        result: ApiVisibilityResult,
1040        dimension: ApiVisibilityDimension,
1041        baseline_context_id: OpaqueContextId,
1042        candidate_context_id: OpaqueContextId,
1043        resource_scope_id: ResourceScopeId,
1044    ) -> Result<Self, ApiVocabularyError> {
1045        if baseline_context_id == candidate_context_id {
1046            return Err(ApiVocabularyError::IdenticalContexts);
1047        }
1048        Ok(Self {
1049            comparison_id,
1050            surface,
1051            pair,
1052            result,
1053            dimension,
1054            baseline_context_id,
1055            candidate_context_id,
1056            resource_scope_id,
1057            observed_at_ms: unix_time_ms(),
1058        })
1059    }
1060
1061    /// Returns the opaque host comparison identifier.
1062    pub fn comparison_id(&self) -> &str {
1063        self.comparison_id.as_str()
1064    }
1065
1066    /// Returns the typed opaque host comparison identifier.
1067    pub const fn typed_comparison_id(&self) -> &ComparisonId {
1068        &self.comparison_id
1069    }
1070
1071    /// Returns the typed opaque baseline context identifier.
1072    pub const fn baseline_context_id(&self) -> &OpaqueContextId {
1073        &self.baseline_context_id
1074    }
1075
1076    /// Returns the typed opaque candidate context identifier.
1077    pub const fn candidate_context_id(&self) -> &OpaqueContextId {
1078        &self.candidate_context_id
1079    }
1080
1081    /// Returns the typed opaque resource-scope identifier.
1082    pub const fn resource_scope_id(&self) -> &ResourceScopeId {
1083        &self.resource_scope_id
1084    }
1085
1086    /// Returns the API surface that was compared.
1087    pub const fn surface(&self) -> ApiSurfaceKind {
1088        self.surface
1089    }
1090
1091    /// Returns the pair of views that was compared.
1092    pub const fn pair(&self) -> ApiVisibilityPairKind {
1093        self.pair
1094    }
1095
1096    /// Returns the paired comparison result.
1097    pub const fn result(&self) -> ApiVisibilityResult {
1098        self.result
1099    }
1100
1101    /// Returns the measured visibility dimension.
1102    pub const fn dimension(&self) -> ApiVisibilityDimension {
1103        self.dimension
1104    }
1105
1106    /// Returns when the host constructed this paired observation.
1107    pub const fn observed_at_ms(&self) -> u64 {
1108        self.observed_at_ms
1109    }
1110
1111    /// Replaces the observation time for deterministic replay or import.
1112    pub fn with_observed_at_ms(mut self, observed_at_ms: u64) -> Self {
1113        self.observed_at_ms = observed_at_ms;
1114        self
1115    }
1116
1117    /// Returns a raw-value-free, stable entity ID unique to this comparison.
1118    ///
1119    /// This SHA-256 identity is pseudonymous, not a cryptographic attestation;
1120    /// hosts should supply non-secret, suitably opaque context handles.
1121    pub fn subject(&self) -> EntityId {
1122        comparison_subject(&self.digest())
1123    }
1124
1125    /// Returns the opaque resource entity that the host compared.
1126    pub fn resource_scope(&self) -> EntityId {
1127        EntityId::new(self.resource_scope_id.as_str().to_owned())
1128            .expect("validated opaque resource scope is a valid entity id")
1129    }
1130
1131    /// Emits the evidence and a stable evidence-backed resource-scope edge.
1132    pub fn to_observation(
1133        &self,
1134        component: impl Into<String>,
1135        reliability: ConfidenceScore,
1136    ) -> Result<ApiVisibilityObservation, ApiVocabularyError> {
1137        let evidence = self.build_evidence(component, reliability)?;
1138        let digest = self.digest();
1139        let resource_scope = self.resource_scope();
1140        let scope_relation = KnowledgeRelation::with_id(
1141            RelationId::parse(format!("api-comparison-scope:{digest}"))?,
1142            evidence.subject().clone(),
1143            resource_scope.clone(),
1144            RelationKind::Custom("api.visibility.resource-scope".to_owned()),
1145            reliability,
1146            evidence.id().clone(),
1147        );
1148        Ok(ApiVisibilityObservation {
1149            evidence,
1150            resource_scope,
1151            scope_relation,
1152        })
1153    }
1154
1155    /// Emits a detached immutable evidence record for this comparison.
1156    ///
1157    /// The source correlation and subject use the same digest, so separate
1158    /// principals or comparison turns cannot contaminate one another. Prefer
1159    /// [`Self::to_observation`] for durable storage; callers using this lower-
1160    /// level method must persist an equivalent resource mapping themselves.
1161    pub fn to_evidence(
1162        &self,
1163        component: impl Into<String>,
1164        reliability: ConfidenceScore,
1165    ) -> Result<Evidence, ApiVocabularyError> {
1166        self.build_evidence(component, reliability)
1167    }
1168
1169    fn build_evidence(
1170        &self,
1171        component: impl Into<String>,
1172        reliability: ConfidenceScore,
1173    ) -> Result<Evidence, ApiVocabularyError> {
1174        if reliability == ConfidenceScore::NONE {
1175            return Err(ApiVocabularyError::ZeroReliability);
1176        }
1177        let digest = self.digest();
1178        let subject = comparison_subject(&digest);
1179        let evidence_id = EvidenceId::parse(format!("api-comparison-evidence:{digest}"))?;
1180        let source = EvidenceSource::new(component, "paired-api-visibility")?
1181            .with_correlation_id(subject.as_str())?;
1182        Ok(Evidence::with_id_at(
1183            evidence_id,
1184            subject,
1185            EvidenceKind::Custom("api.visibility-comparison".to_owned()),
1186            ApiEvidencePredicate::visibility(self.surface, self.pair, self.result).into(),
1187            self.dimension.into(),
1188            source,
1189            reliability,
1190            self.observed_at_ms,
1191        ))
1192    }
1193
1194    fn digest(&self) -> String {
1195        let mut digest = Sha256::new();
1196        for value in [
1197            self.comparison_id.as_str(),
1198            self.surface.as_str(),
1199            self.pair.as_str(),
1200            self.result.as_str(),
1201            self.dimension.as_str(),
1202            self.baseline_context_id.as_str(),
1203            self.candidate_context_id.as_str(),
1204            self.resource_scope_id.as_str(),
1205        ] {
1206            let bytes = value.as_bytes();
1207            digest.update(u64::try_from(bytes.len()).unwrap_or(u64::MAX).to_be_bytes());
1208            digest.update(bytes);
1209        }
1210        hex::encode(digest.finalize())
1211    }
1212}
1213
1214fn comparison_subject(digest: &str) -> EntityId {
1215    EntityId::new(format!("api-comparison:{digest}"))
1216        .expect("a prefixed SHA-256 digest is a valid entity id")
1217}
1218
1219impl<'de> Deserialize<'de> for ApiVisibilityComparison {
1220    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
1221    where
1222        D: Deserializer<'de>,
1223    {
1224        #[derive(Deserialize)]
1225        struct WireComparison {
1226            comparison_id: String,
1227            surface: ApiSurfaceKind,
1228            pair: ApiVisibilityPairKind,
1229            result: ApiVisibilityResult,
1230            dimension: ApiVisibilityDimension,
1231            baseline_context_id: String,
1232            candidate_context_id: String,
1233            resource_scope_id: String,
1234            observed_at_ms: u64,
1235        }
1236
1237        let wire = WireComparison::deserialize(deserializer)?;
1238        Self::new(
1239            wire.comparison_id,
1240            wire.surface,
1241            wire.pair,
1242            wire.result,
1243            wire.dimension,
1244            wire.baseline_context_id,
1245            wire.candidate_context_id,
1246            wire.resource_scope_id,
1247        )
1248        .map(|comparison| comparison.with_observed_at_ms(wire.observed_at_ms))
1249        .map_err(serde::de::Error::custom)
1250    }
1251}
1252
1253fn opaque_context(
1254    value: impl Into<String>,
1255    field: &'static str,
1256) -> Result<String, ApiVocabularyError> {
1257    let value = value.into();
1258    if value.trim().is_empty() {
1259        return Err(ApiVocabularyError::EmptyContext { field });
1260    }
1261    if value.len() > MAX_OPAQUE_CONTEXT_BYTES {
1262        return Err(ApiVocabularyError::ContextTooLong {
1263            field,
1264            maximum: MAX_OPAQUE_CONTEXT_BYTES,
1265        });
1266    }
1267    Ok(value)
1268}
1269
1270fn unix_time_ms() -> u64 {
1271    SystemTime::now()
1272        .duration_since(UNIX_EPOCH)
1273        .map(|duration| u64::try_from(duration.as_millis()).unwrap_or(u64::MAX))
1274        .unwrap_or(0)
1275}
1276
1277#[cfg(test)]
1278mod tests {
1279    use std::collections::BTreeSet;
1280
1281    use super::*;
1282
1283    #[test]
1284    fn fixed_http_descriptors_are_unique_and_preserve_wire_shape() {
1285        let descriptors = HttpEvidencePredicate::fixed();
1286        let unique = descriptors
1287            .iter()
1288            .map(|descriptor| descriptor.dotted())
1289            .collect::<BTreeSet<_>>();
1290
1291        assert_eq!(unique.len(), descriptors.len());
1292        assert_eq!(
1293            serde_json::to_value(HttpEvidencePredicate::RESPONSE_STATUS.into_knowledge()).unwrap(),
1294            serde_json::json!({"namespace": "http.response", "name": "status"})
1295        );
1296        for descriptor in descriptors {
1297            assert_eq!(descriptor.into_knowledge().dotted(), descriptor.dotted());
1298        }
1299    }
1300
1301    #[test]
1302    fn fixed_web_discovery_descriptors_are_unique_ordered_and_preserve_wire_shape() {
1303        let descriptors = WebDiscoveryEvidencePredicate::fixed();
1304        let dotted: Vec<_> = descriptors
1305            .iter()
1306            .map(|descriptor| descriptor.dotted())
1307            .collect();
1308        let unique = dotted.iter().copied().collect::<BTreeSet<_>>();
1309
1310        assert_eq!(descriptors.len(), 21);
1311        assert_eq!(unique.len(), descriptors.len());
1312        assert_eq!(
1313            dotted,
1314            vec![
1315                "web.discovery.document-projected",
1316                "web.discovery.document-body-incomplete",
1317                "web.discovery.document-partial-representation",
1318                "web.discovery.document-invalid-utf8",
1319                "web.discovery.document-route-limit-reached",
1320                "web.discovery.document-form-limit-reached",
1321                "web.discovery.document-control-limit-reached",
1322                "web.discovery.document-query-name-limit-reached",
1323                "web.discovery.document-url-byte-limit-reached",
1324                "web.discovery.assessment-subject-limit-reached",
1325                "web.discovery.assessment-depth-limit-reached",
1326                "web.discovery.assessment-retained-url-byte-limit-reached",
1327                "web.discovery.document-outside-origin-reference-count",
1328                "web.discovery.get-route",
1329                "web.discovery.head-route",
1330                "web.discovery.route-query-parameter-names",
1331                "web.discovery.get-form-action",
1332                "web.discovery.post-form-action",
1333                "web.discovery.dialog-form-action",
1334                "web.discovery.form-query-parameter-names",
1335                "web.discovery.form-control-names",
1336            ]
1337        );
1338        assert_eq!(
1339            serde_json::to_value(
1340                WebDiscoveryEvidencePredicate::DOCUMENT_PROJECTED.into_knowledge()
1341            )
1342            .unwrap(),
1343            serde_json::json!({"namespace": "web.discovery", "name": "document-projected"})
1344        );
1345        for descriptor in descriptors {
1346            assert_eq!(descriptor.into_knowledge().dotted(), descriptor.dotted());
1347        }
1348    }
1349
1350    #[test]
1351    fn dynamic_header_family_remains_open() {
1352        let predicate = HttpEvidencePredicate::response_header("x-private-signal").unwrap();
1353
1354        assert_eq!(predicate.namespace(), "http.header");
1355        assert_eq!(predicate.name(), "x-private-signal");
1356    }
1357
1358    fn comparison(candidate: &str) -> ApiVisibilityComparison {
1359        ApiVisibilityComparison::new(
1360            "comparison-7",
1361            ApiSurfaceKind::GraphQl,
1362            ApiVisibilityPairKind::AuthorizationContext,
1363            ApiVisibilityResult::Different,
1364            ApiVisibilityDimension::Fields,
1365            "anonymous",
1366            candidate,
1367            "resource-42",
1368        )
1369        .unwrap()
1370    }
1371
1372    #[test]
1373    fn comparison_emits_one_atomic_pseudonymous_observation() {
1374        let comparison = comparison("member");
1375        let evidence = comparison
1376            .to_evidence("api.visibility", ConfidenceScore::from_percent(95).unwrap())
1377            .unwrap();
1378
1379        assert!(evidence.subject().as_str().starts_with("api-comparison:"));
1380        assert_eq!(
1381            evidence.predicate(),
1382            &ApiEvidencePredicate::GRAPHQL_AUTHORIZATION_CONTEXT_DIFFERENCE.into_knowledge()
1383        );
1384        assert_eq!(evidence.value(), &EvidenceValue::Text("fields".to_owned()));
1385        assert_eq!(
1386            evidence.source().correlation_id(),
1387            Some(evidence.subject().as_str())
1388        );
1389        assert_eq!(evidence.source().method(), "paired-api-visibility");
1390        let encoded = serde_json::to_string(&evidence).unwrap();
1391        for secret_adjacent_value in ["anonymous", "member", "resource-42"] {
1392            assert!(!encoded.contains(secret_adjacent_value));
1393        }
1394    }
1395
1396    #[test]
1397    fn comparison_bundle_links_pseudonymous_subject_to_resource_scope() {
1398        let observation = comparison("member")
1399            .with_observed_at_ms(1_000)
1400            .to_observation("api.visibility", ConfidenceScore::MAX)
1401            .unwrap();
1402
1403        assert_eq!(
1404            observation.scope_relation().from(),
1405            observation.evidence().subject()
1406        );
1407        assert_eq!(
1408            observation.scope_relation().to(),
1409            observation.resource_scope()
1410        );
1411        assert_eq!(observation.resource_scope().as_str(), "resource-42");
1412        assert_eq!(
1413            observation.scope_relation().evidence_ids(),
1414            &std::collections::BTreeSet::from([observation.evidence().id().clone()])
1415        );
1416        assert!(matches!(
1417            observation.scope_relation().kind(),
1418            RelationKind::Custom(kind) if kind == "api.visibility.resource-scope"
1419        ));
1420        assert!(observation
1421            .scope_relation()
1422            .id()
1423            .as_str()
1424            .starts_with("api-comparison-scope:"));
1425    }
1426
1427    #[test]
1428    fn comparison_identity_is_stable_and_context_scoped() {
1429        let paired = comparison("member").with_observed_at_ms(1_000);
1430        let first = paired
1431            .to_evidence("api.visibility", ConfidenceScore::MAX)
1432            .unwrap();
1433        let replay = paired
1434            .to_evidence("api.visibility", ConfidenceScore::MAX)
1435            .unwrap();
1436        let later = comparison("member")
1437            .with_observed_at_ms(2_000)
1438            .to_evidence("api.visibility", ConfidenceScore::MAX)
1439            .unwrap();
1440
1441        assert_eq!(
1442            comparison("member").subject(),
1443            comparison("member").subject()
1444        );
1445        assert_eq!(first, replay);
1446        assert_eq!(first.id(), later.id());
1447        assert_ne!(first, later);
1448        assert_ne!(
1449            comparison("member").subject(),
1450            comparison("admin").subject()
1451        );
1452    }
1453
1454    #[test]
1455    fn typed_comparison_identifiers_preserve_legacy_wire_and_identity() {
1456        let typed = ApiVisibilityComparison::new_typed(
1457            ComparisonId::new("comparison-7").unwrap(),
1458            ApiSurfaceKind::GraphQl,
1459            ApiVisibilityPairKind::AuthorizationContext,
1460            ApiVisibilityResult::Different,
1461            ApiVisibilityDimension::Fields,
1462            OpaqueContextId::new("anonymous").unwrap(),
1463            OpaqueContextId::new("member").unwrap(),
1464            ResourceScopeId::new("resource-42").unwrap(),
1465        )
1466        .unwrap()
1467        .with_observed_at_ms(1_234);
1468        let legacy = comparison("member").with_observed_at_ms(1_234);
1469
1470        assert_eq!(typed, legacy);
1471        assert_eq!(typed.comparison_id(), "comparison-7");
1472        assert_eq!(typed.typed_comparison_id().as_str(), "comparison-7");
1473        assert_eq!(typed.baseline_context_id().as_str(), "anonymous");
1474        assert_eq!(typed.candidate_context_id().as_str(), "member");
1475        assert_eq!(typed.resource_scope_id().as_str(), "resource-42");
1476        assert_eq!(
1477            typed.subject().as_str(),
1478            "api-comparison:2c9736b8ec3f7a945eba1822c36e9bd033ff45b24421ec387859e977b6d434b2"
1479        );
1480        assert_eq!(
1481            serde_json::to_value(&typed).unwrap(),
1482            serde_json::json!({
1483                "comparison_id": "comparison-7",
1484                "surface": "graphql",
1485                "pair": "authorization-context",
1486                "result": "different",
1487                "dimension": "fields",
1488                "baseline_context_id": "anonymous",
1489                "candidate_context_id": "member",
1490                "resource_scope_id": "resource-42",
1491                "observed_at_ms": 1_234,
1492            })
1493        );
1494    }
1495
1496    #[test]
1497    fn opaque_identifier_round_trips_validate_and_redact_debug_output() {
1498        let comparison_id = ComparisonId::new("sensitive-comparison-handle").unwrap();
1499        let context_id = OpaqueContextId::new("sensitive-context-handle").unwrap();
1500        let resource_id = ResourceScopeId::new("sensitive-resource-handle").unwrap();
1501
1502        assert_eq!(
1503            serde_json::from_value::<ComparisonId>(serde_json::to_value(&comparison_id).unwrap())
1504                .unwrap(),
1505            comparison_id
1506        );
1507        assert_eq!(
1508            serde_json::from_value::<OpaqueContextId>(serde_json::to_value(&context_id).unwrap())
1509                .unwrap(),
1510            context_id
1511        );
1512        assert_eq!(
1513            serde_json::from_value::<ResourceScopeId>(serde_json::to_value(&resource_id).unwrap())
1514                .unwrap(),
1515            resource_id
1516        );
1517        assert!(serde_json::from_str::<OpaqueContextId>("\" \"").is_err());
1518
1519        let debug = format!("{comparison_id:?} {context_id:?} {resource_id:?}");
1520        for secret in [
1521            "sensitive-comparison-handle",
1522            "sensitive-context-handle",
1523            "sensitive-resource-handle",
1524        ] {
1525            assert!(!debug.contains(secret));
1526        }
1527        assert_eq!(debug.matches("<redacted>").count(), 3);
1528        assert_eq!(
1529            ComparisonId::new("consumed-comparison")
1530                .unwrap()
1531                .into_string(),
1532            "consumed-comparison"
1533        );
1534    }
1535
1536    #[test]
1537    fn comparison_debug_redacts_every_opaque_identifier() {
1538        let comparison = ApiVisibilityComparison::new(
1539            "sensitive-comparison",
1540            ApiSurfaceKind::JsonHttp,
1541            ApiVisibilityPairKind::AuthorizationContext,
1542            ApiVisibilityResult::Different,
1543            ApiVisibilityDimension::Fields,
1544            "sensitive-baseline",
1545            "sensitive-candidate",
1546            "sensitive-resource",
1547        )
1548        .unwrap();
1549
1550        let debug = format!("{comparison:?}");
1551        for secret in [
1552            "sensitive-comparison",
1553            "sensitive-baseline",
1554            "sensitive-candidate",
1555            "sensitive-resource",
1556        ] {
1557            assert!(!debug.contains(secret));
1558        }
1559        assert_eq!(debug.matches("<redacted>").count(), 4);
1560        assert!(debug.contains("JsonHttp"));
1561    }
1562
1563    #[test]
1564    fn comparison_round_trip_revalidates_bounded_contexts() {
1565        let paired = comparison("member");
1566        let encoded = serde_json::to_value(&paired).unwrap();
1567
1568        assert_eq!(encoded["surface"], "graphql");
1569
1570        assert_eq!(
1571            serde_json::from_value::<ApiVisibilityComparison>(encoded).unwrap(),
1572            paired
1573        );
1574        assert!(ApiVisibilityComparison::new(
1575            " ",
1576            ApiSurfaceKind::JsonHttp,
1577            ApiVisibilityPairKind::UiApi,
1578            ApiVisibilityResult::Equivalent,
1579            ApiVisibilityDimension::Status,
1580            "ui",
1581            "api",
1582            "scope",
1583        )
1584        .is_err());
1585        assert!(matches!(
1586            ApiVisibilityComparison::new(
1587                "comparison",
1588                ApiSurfaceKind::JsonHttp,
1589                ApiVisibilityPairKind::AuthorizationContext,
1590                ApiVisibilityResult::Different,
1591                ApiVisibilityDimension::Fields,
1592                "same-view",
1593                "same-view",
1594                "scope",
1595            ),
1596            Err(ApiVocabularyError::IdenticalContexts)
1597        ));
1598        assert!(matches!(
1599            comparison("member").to_evidence("api.visibility", ConfidenceScore::NONE),
1600            Err(ApiVocabularyError::ZeroReliability)
1601        ));
1602        assert!(ApiVisibilityComparison::new(
1603            "comparison",
1604            ApiSurfaceKind::JsonHttp,
1605            ApiVisibilityPairKind::UiApi,
1606            ApiVisibilityResult::Equivalent,
1607            ApiVisibilityDimension::Status,
1608            "a".repeat(MAX_OPAQUE_CONTEXT_BYTES + 1),
1609            "api",
1610            "scope",
1611        )
1612        .is_err());
1613    }
1614}