Skip to main content

glass/browser/session/
knowledge.rs

1//! Versioned, bounded browser knowledge records.
2//!
3//! Knowledge is an optimization and an explanation surface. A record may help
4//! recognize a recurring page or reduce inspection work, but it never contains
5//! an executable target reference and never authorizes a browser mutation.
6
7use super::{SemanticIntentCandidate, SemanticObservation, target_fingerprint_digest};
8use serde::{Deserialize, Serialize};
9use serde_json::{Value, json};
10use sha2::{Digest, Sha256};
11use std::collections::BTreeSet;
12use std::fmt;
13use url::Url;
14
15pub const KNOWLEDGE_SCHEMA_VERSION: u32 = 1;
16pub const MAX_KNOWLEDGE_RECORDS: usize = 256;
17const MAX_RECORD_ID_BYTES: usize = 128;
18const MAX_SCOPE_VALUE_BYTES: usize = 256;
19const MAX_ROLE_BYTES: usize = 64;
20const MAX_TIMESTAMP_BYTES: usize = 64;
21const MAX_LANDMARKS: usize = 32;
22const MAX_HISTORY: usize = 32;
23const MAX_DATA_BYTES: usize = 16 * 1024;
24const MAX_RECORD_BYTES: usize = 64 * 1024;
25const MAX_JSON_DEPTH: usize = 8;
26const MAX_JSON_OBJECT_ENTRIES: usize = 64;
27const MAX_JSON_ARRAY_ENTRIES: usize = 64;
28const MAX_JSON_STRING_BYTES: usize = 4096;
29
30/// Current browser/session dimensions used to assess one stored record.
31#[derive(Debug, Clone, PartialEq, Eq)]
32pub struct KnowledgeLookupContext {
33    pub origin: String,
34    pub path: String,
35    pub profile_scope: KnowledgeProfileScope,
36    pub profile_key: Option<String>,
37    pub locale: Option<String>,
38    pub tenant_key: Option<String>,
39    pub browser_family: String,
40    pub browser_version: Option<String>,
41    pub glass_schema_version: u32,
42    pub policy_preset: String,
43    pub landmarks: Vec<String>,
44    pub now_epoch_seconds: i64,
45}
46
47/// Explicit session inputs used to construct a lookup context from an
48/// observation.
49#[derive(Debug, Clone, PartialEq, Eq)]
50pub struct KnowledgeLookupOptions {
51    pub profile_scope: KnowledgeProfileScope,
52    pub profile_key: Option<String>,
53    pub locale: Option<String>,
54    pub tenant_key: Option<String>,
55    pub browser_family: String,
56    pub browser_version: Option<String>,
57    pub glass_schema_version: u32,
58    pub policy_preset: String,
59    pub now_epoch_seconds: i64,
60}
61
62/// Inputs for creating one page-family record from fresh semantic evidence.
63#[derive(Debug, Clone, PartialEq, Eq)]
64pub struct KnowledgeRecordBuildOptions {
65    pub record_id: String,
66    pub scope: KnowledgeScope,
67    pub glass_version: String,
68    pub observed_at: String,
69}
70
71impl KnowledgeLookupContext {
72    /// Build matching dimensions from a semantic observation and explicit
73    /// session scope. Current element references are never retained here.
74    pub fn from_observation(
75        observation: &SemanticObservation,
76        options: KnowledgeLookupOptions,
77    ) -> Result<Self, KnowledgeValidationError> {
78        let url = Url::parse(&observation.route.url).map_err(|error| {
79            KnowledgeValidationError::new("observation.route.url", format!("invalid URL: {error}"))
80        })?;
81        let origin = url.origin().ascii_serialization();
82        let path = if url.path().is_empty() {
83            "/".to_string()
84        } else {
85            url.path().to_string()
86        };
87        let mut landmarks = BTreeSet::new();
88        landmarks.insert(
89            serde_json::to_string(&observation.page.kind).map_err(|error| {
90                KnowledgeValidationError::new("observation.page.kind", error.to_string())
91            })?,
92        );
93        for region in &observation.regions {
94            landmarks.insert(serde_json::to_string(&region.kind).map_err(|error| {
95                KnowledgeValidationError::new("observation.regions.kind", error.to_string())
96            })?);
97        }
98        Ok(Self {
99            origin,
100            path,
101            profile_scope: options.profile_scope,
102            profile_key: options.profile_key,
103            locale: options.locale,
104            tenant_key: options.tenant_key,
105            browser_family: options.browser_family,
106            browser_version: options.browser_version,
107            glass_schema_version: options.glass_schema_version,
108            policy_preset: options.policy_preset,
109            landmarks: landmarks
110                .into_iter()
111                .map(|landmark| landmark.trim_matches('"').to_string())
112                .collect(),
113            now_epoch_seconds: options.now_epoch_seconds,
114        })
115    }
116}
117
118/// Why one remembered record matched or failed to match current state.
119#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
120#[serde(rename_all = "camelCase")]
121pub enum KnowledgeSignalKind {
122    OriginMatch,
123    PathMatch,
124    ProfileScopeMatch,
125    LocaleMatch,
126    TenantMatch,
127    BrowserMatch,
128    SchemaMatch,
129    PolicyMatch,
130    LandmarkMatch,
131    FreshnessMatch,
132}
133
134/// One bounded positive or negative assessment explanation.
135#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
136#[serde(rename_all = "camelCase")]
137pub struct KnowledgeAssessmentSignal {
138    pub kind: KnowledgeSignalKind,
139    pub detail: String,
140}
141
142/// Eligibility state after current scope, freshness, and landmark checks.
143#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
144#[serde(rename_all = "camelCase")]
145pub enum KnowledgeAssessmentStatus {
146    Eligible,
147    OutOfScope,
148    Stale,
149    Contradicted,
150    Quarantined,
151}
152
153/// Fresh-state assessment of one stored record. This result contains no target
154/// reference and cannot authorize a browser mutation.
155#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
156#[serde(rename_all = "camelCase")]
157pub struct KnowledgeAssessment {
158    pub record_id: String,
159    pub status: KnowledgeAssessmentStatus,
160    #[serde(default, skip_serializing_if = "Vec::is_empty")]
161    pub signals: Vec<KnowledgeAssessmentSignal>,
162    #[serde(default, skip_serializing_if = "Vec::is_empty")]
163    pub conflicts: Vec<String>,
164    #[serde(default, skip_serializing_if = "Vec::is_empty")]
165    pub missing_landmarks: Vec<String>,
166    #[serde(default, skip_serializing_if = "Option::is_none")]
167    pub age_seconds: Option<i64>,
168}
169
170/// Whether a semantic observation consulted the local knowledge store.
171#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
172#[serde(rename_all = "camelCase")]
173pub enum KnowledgeObservationMode {
174    FreshOnly,
175    Assessed,
176}
177
178/// Fresh semantic observation plus explicit, non-authorizing knowledge
179/// assessment evidence.
180#[derive(Debug, Clone, Serialize, Deserialize)]
181#[serde(rename_all = "camelCase")]
182pub struct KnowledgeObservationReport {
183    pub observation: SemanticObservation,
184    pub mode: KnowledgeObservationMode,
185    #[serde(default, skip_serializing_if = "Vec::is_empty")]
186    pub assessments: Vec<KnowledgeAssessment>,
187    #[serde(default, skip_serializing_if = "Vec::is_empty")]
188    pub eligible_record_ids: Vec<String>,
189    #[serde(default, skip_serializing_if = "Vec::is_empty")]
190    pub stale_record_ids: Vec<String>,
191    #[serde(default, skip_serializing_if = "Vec::is_empty")]
192    pub out_of_scope_record_ids: Vec<String>,
193}
194
195/// Knowledge record categories supported by the versioned store contract.
196#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
197#[serde(rename_all = "camelCase")]
198pub enum KnowledgeRecordKind {
199    PageFamily,
200    RegionModel,
201    TargetFingerprint,
202    RouteTransition,
203    WorkflowEntryPoint,
204    VerifiedPostcondition,
205    ExtractionShape,
206    InvalidationRule,
207}
208
209/// Confidence and lifecycle state of one knowledge record.
210#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
211#[serde(rename_all = "lowercase")]
212pub enum KnowledgeConfidence {
213    Candidate,
214    Observed,
215    Verified,
216    Stale,
217    Contradicted,
218    Quarantined,
219}
220
221/// Scope dimensions that prevent knowledge from crossing incompatible sessions.
222#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
223#[serde(rename_all = "camelCase")]
224pub struct KnowledgeScope {
225    pub origin: String,
226    pub path_pattern: String,
227    pub profile_scope: KnowledgeProfileScope,
228    #[serde(default, skip_serializing_if = "Option::is_none")]
229    pub profile_key: Option<String>,
230    #[serde(default, skip_serializing_if = "Option::is_none")]
231    pub locale: Option<String>,
232    #[serde(default, skip_serializing_if = "Option::is_none")]
233    pub tenant_key: Option<String>,
234    pub browser_family: String,
235    #[serde(default, skip_serializing_if = "Option::is_none")]
236    pub browser_version_range: Option<String>,
237    pub glass_schema_version: u32,
238    pub policy_preset: String,
239}
240
241/// Whether a record is anonymous, generally authenticated, or bound to one
242/// caller-selected profile identity class.
243#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
244#[serde(rename_all = "camelCase")]
245pub enum KnowledgeProfileScope {
246    Anonymous,
247    Authenticated,
248    ProfileBound,
249}
250
251/// Provenance and verification counters for a knowledge record.
252#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
253#[serde(rename_all = "camelCase")]
254pub struct KnowledgeSource {
255    pub first_seen_at: String,
256    pub last_verified_at: String,
257    pub glass_version: String,
258    pub verification_count: u32,
259}
260
261/// Conditions that make remembered knowledge stale or unusable.
262#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
263#[serde(rename_all = "camelCase")]
264pub struct KnowledgeInvalidation {
265    #[serde(default, skip_serializing_if = "Option::is_none")]
266    pub max_age_seconds: Option<u64>,
267    #[serde(default, skip_serializing_if = "Vec::is_empty")]
268    pub required_landmarks: Vec<String>,
269}
270
271/// One bounded, auditable lifecycle transition.
272#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
273#[serde(rename_all = "camelCase")]
274pub struct KnowledgeLifecycleEvent {
275    pub from: KnowledgeConfidence,
276    pub to: KnowledgeConfidence,
277    pub reason: String,
278    pub observed_at: String,
279}
280
281/// A versioned knowledge record. `data` is deliberately opaque to this layer,
282/// but its shape, size, and sensitive field names are strictly bounded.
283#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
284#[serde(rename_all = "camelCase")]
285pub struct KnowledgeRecord {
286    pub schema_version: u32,
287    pub record_id: String,
288    pub kind: KnowledgeRecordKind,
289    pub scope: KnowledgeScope,
290    pub source: KnowledgeSource,
291    pub confidence: KnowledgeConfidence,
292    pub invalidation: KnowledgeInvalidation,
293    pub data: Value,
294    #[serde(default, skip_serializing_if = "Vec::is_empty")]
295    pub history: Vec<KnowledgeLifecycleEvent>,
296}
297
298impl KnowledgeRecord {
299    /// Build an observed page-family record from fresh semantic structure.
300    ///
301    /// Only page/region kinds are retained. Current revisioned target
302    /// references, labels, text, accessibility trees, and form values are not
303    /// copied into persistent knowledge.
304    pub fn from_page_observation(
305        observation: &SemanticObservation,
306        options: KnowledgeRecordBuildOptions,
307    ) -> Result<Self, KnowledgeValidationError> {
308        observation.validate().map_err(|error| {
309            KnowledgeValidationError::new("observation", format!("invalid observation: {error}"))
310        })?;
311        validate_text("recordId", &options.record_id, MAX_RECORD_ID_BYTES, false)?;
312        validate_text(
313            "source.glassVersion",
314            &options.glass_version,
315            MAX_SCOPE_VALUE_BYTES,
316            false,
317        )?;
318        validate_timestamp("source.observedAt", &options.observed_at)?;
319        validate_scope(&options.scope)?;
320        let page_kind = serde_json::to_value(observation.page.kind)
321            .map_err(|error| KnowledgeValidationError::new("data.pageKind", error.to_string()))?;
322        let region_kinds = observation
323            .regions
324            .iter()
325            .map(|region| serde_json::to_value(region.kind))
326            .collect::<Result<Vec<_>, _>>()
327            .map_err(|error| {
328                KnowledgeValidationError::new("data.regionKinds", error.to_string())
329            })?;
330        let mut required_landmarks = BTreeSet::new();
331        required_landmarks.insert(page_kind.as_str().unwrap_or_default().to_owned());
332        for kind in &region_kinds {
333            if let Some(kind) = kind.as_str() {
334                required_landmarks.insert(kind.to_owned());
335            }
336        }
337        let record = Self {
338            schema_version: KNOWLEDGE_SCHEMA_VERSION,
339            record_id: options.record_id,
340            kind: KnowledgeRecordKind::PageFamily,
341            scope: options.scope,
342            source: KnowledgeSource {
343                first_seen_at: options.observed_at.clone(),
344                last_verified_at: options.observed_at,
345                glass_version: options.glass_version,
346                verification_count: 0,
347            },
348            confidence: KnowledgeConfidence::Observed,
349            invalidation: KnowledgeInvalidation {
350                max_age_seconds: Some(604_800),
351                required_landmarks: required_landmarks.into_iter().collect(),
352            },
353            data: json!({
354                "pageKind": page_kind,
355                "regionKinds": region_kinds,
356            }),
357            history: Vec::new(),
358        };
359        record.validate()?;
360        Ok(record)
361    }
362
363    /// Build an observed target-fingerprint record from one fresh intent
364    /// candidate. Only a digest and non-sensitive semantic dimensions are
365    /// retained; the candidate's current reference and accessible name are
366    /// never persisted.
367    pub fn from_intent_candidate(
368        candidate: &SemanticIntentCandidate,
369        options: KnowledgeRecordBuildOptions,
370    ) -> Result<Self, KnowledgeValidationError> {
371        let fingerprint = candidate.fingerprint.as_ref().ok_or_else(|| {
372            KnowledgeValidationError::new(
373                "candidate.fingerprint",
374                "fresh intent candidates require a target fingerprint",
375            )
376        })?;
377        validate_text("recordId", &options.record_id, MAX_RECORD_ID_BYTES, false)?;
378        validate_text(
379            "source.glassVersion",
380            &options.glass_version,
381            MAX_SCOPE_VALUE_BYTES,
382            false,
383        )?;
384        validate_timestamp("source.observedAt", &options.observed_at)?;
385        validate_scope(&options.scope)?;
386        validate_text("candidate.role", &candidate.role, MAX_ROLE_BYTES, false)?;
387        let digest = target_fingerprint_digest(
388            &candidate.role,
389            &candidate.name,
390            candidate.input_type.as_deref(),
391            candidate.region_kind,
392            fingerprint.purpose,
393        );
394        let required_landmarks = candidate
395            .region_kind
396            .map(|kind| serde_json::to_string(&kind).unwrap_or_default())
397            .into_iter()
398            .map(|landmark| landmark.trim_matches('"').to_string())
399            .collect();
400        let record = Self {
401            schema_version: KNOWLEDGE_SCHEMA_VERSION,
402            record_id: options.record_id,
403            kind: KnowledgeRecordKind::TargetFingerprint,
404            scope: options.scope,
405            source: KnowledgeSource {
406                first_seen_at: options.observed_at.clone(),
407                last_verified_at: options.observed_at,
408                glass_version: options.glass_version,
409                verification_count: 0,
410            },
411            confidence: KnowledgeConfidence::Observed,
412            invalidation: KnowledgeInvalidation {
413                max_age_seconds: Some(604_800),
414                required_landmarks,
415            },
416            data: json!({
417                "fingerprint": digest,
418                "role": candidate.role,
419                "regionKind": candidate.region_kind,
420                "purpose": fingerprint.purpose,
421            }),
422            history: Vec::new(),
423        };
424        record.validate()?;
425        Ok(record)
426    }
427
428    /// Build a candidate workflow-entry record from a validated definition.
429    /// Only hashed workflow identity, hashed step/output IDs, and bounded
430    /// shape counts are retained; inputs, locators, predicates, and values are
431    /// deliberately excluded.
432    pub fn from_workflow_definition(
433        workflow: &super::WorkflowDefinition,
434        options: KnowledgeRecordBuildOptions,
435    ) -> Result<Self, KnowledgeValidationError> {
436        workflow.validate().map_err(|error| {
437            KnowledgeValidationError::new("workflow", format!("invalid workflow: {error}"))
438        })?;
439        validate_text("recordId", &options.record_id, MAX_RECORD_ID_BYTES, false)?;
440        validate_text(
441            "source.glassVersion",
442            &options.glass_version,
443            MAX_SCOPE_VALUE_BYTES,
444            false,
445        )?;
446        validate_timestamp("source.observedAt", &options.observed_at)?;
447        validate_scope(&options.scope)?;
448        let workflow_hash = hash_knowledge_identity(&[&workflow.name, &workflow.workflow_version]);
449        let step_hashes: Vec<String> = workflow
450            .steps
451            .iter()
452            .map(|step| hash_knowledge_identity(&[&step.id]))
453            .collect();
454        let output_hashes: Vec<String> = workflow
455            .outputs
456            .keys()
457            .map(|key| hash_knowledge_identity(&[key]))
458            .collect();
459        let record = Self {
460            schema_version: KNOWLEDGE_SCHEMA_VERSION,
461            record_id: options.record_id,
462            kind: KnowledgeRecordKind::WorkflowEntryPoint,
463            scope: options.scope,
464            source: KnowledgeSource {
465                first_seen_at: options.observed_at.clone(),
466                last_verified_at: options.observed_at,
467                glass_version: options.glass_version,
468                verification_count: 0,
469            },
470            confidence: KnowledgeConfidence::Candidate,
471            invalidation: KnowledgeInvalidation {
472                max_age_seconds: Some(604_800),
473                required_landmarks: Vec::new(),
474            },
475            data: json!({
476                "workflowHash": workflow_hash,
477                "stepHashes": step_hashes,
478                "outputHashes": output_hashes,
479                "stepCount": workflow.steps.len(),
480                "intentStepCount": workflow.steps.iter().filter(|step| step.intent.is_some()).count(),
481                "postconditionCount": workflow.steps.iter().filter(|step| step.expect.is_some()).count() + 1,
482            }),
483            history: Vec::new(),
484        };
485        record.validate()?;
486        Ok(record)
487    }
488
489    /// Validate one record against the stable contract and all payload bounds.
490    pub fn validate(&self) -> Result<(), KnowledgeValidationError> {
491        if self.schema_version != KNOWLEDGE_SCHEMA_VERSION {
492            return Err(KnowledgeValidationError::new(
493                "schemaVersion",
494                format!(
495                    "unsupported schema version {}; expected {}",
496                    self.schema_version, KNOWLEDGE_SCHEMA_VERSION
497                ),
498            ));
499        }
500        validate_text("recordId", &self.record_id, MAX_RECORD_ID_BYTES, false)?;
501        validate_scope(&self.scope)?;
502        validate_source(&self.source)?;
503        validate_invalidation(&self.invalidation)?;
504        if self.history.len() > MAX_HISTORY {
505            return Err(KnowledgeValidationError::new(
506                "history",
507                format!("contains more than {MAX_HISTORY} events"),
508            ));
509        }
510        for (index, event) in self.history.iter().enumerate() {
511            validate_text(
512                &format!("history[{index}].reason"),
513                &event.reason,
514                MAX_SCOPE_VALUE_BYTES,
515                false,
516            )?;
517            validate_timestamp(&format!("history[{index}].observedAt"), &event.observed_at)?;
518        }
519        if !matches!(self.data, Value::Object(_) | Value::Array(_)) {
520            return Err(KnowledgeValidationError::new(
521                "data",
522                "must be a JSON object or array",
523            ));
524        }
525        validate_json_value("data", &self.data, 0)?;
526        let data_bytes = serde_json::to_vec(&self.data).map_err(|error| {
527            KnowledgeValidationError::new("data", format!("cannot serialize data: {error}"))
528        })?;
529        if data_bytes.len() > MAX_DATA_BYTES {
530            return Err(KnowledgeValidationError::new(
531                "data",
532                format!("exceeds the {MAX_DATA_BYTES}-byte limit"),
533            ));
534        }
535        let record_bytes = serde_json::to_vec(self).map_err(|error| {
536            KnowledgeValidationError::new("$", format!("cannot serialize record: {error}"))
537        })?;
538        if record_bytes.len() > MAX_RECORD_BYTES {
539            return Err(KnowledgeValidationError::new(
540                "$",
541                format!("record exceeds the {MAX_RECORD_BYTES}-byte limit"),
542            ));
543        }
544        Ok(())
545    }
546
547    /// Serialize a validated record with deterministic object-key ordering.
548    pub fn to_canonical_json(&self) -> Result<String, KnowledgeValidationError> {
549        self.validate()?;
550        serde_json::to_string(self)
551            .map_err(|error| KnowledgeValidationError::new("$", error.to_string()))
552    }
553
554    /// Hash the canonical record for integrity checks and stable comparisons.
555    pub fn content_hash(&self) -> Result<String, KnowledgeValidationError> {
556        let canonical = self.to_canonical_json()?;
557        let digest = Sha256::digest(canonical.as_bytes());
558        Ok(format!("sha256:{digest:x}"))
559    }
560
561    /// Assess this record against current scope and fresh observation signals.
562    /// A positive assessment never includes an executable target reference.
563    pub fn assess(&self, context: &KnowledgeLookupContext) -> KnowledgeAssessment {
564        let mut signals = Vec::new();
565        let mut conflicts = Vec::new();
566        let mut missing_landmarks = Vec::new();
567
568        if self.scope.origin == context.origin {
569            signals.push(signal(KnowledgeSignalKind::OriginMatch, "origin matches"));
570        } else {
571            conflicts.push("origin does not match".into());
572        }
573        if path_matches(&self.scope.path_pattern, &context.path) {
574            signals.push(signal(
575                KnowledgeSignalKind::PathMatch,
576                "path pattern matches",
577            ));
578        } else {
579            conflicts.push("path is outside the record pattern".into());
580        }
581        if self.scope.profile_scope == context.profile_scope
582            && self.scope.profile_key == context.profile_key
583        {
584            signals.push(signal(
585                KnowledgeSignalKind::ProfileScopeMatch,
586                "profile scope matches",
587            ));
588        } else {
589            conflicts.push("profile scope does not match".into());
590        }
591        compare_optional_scope(
592            &mut signals,
593            &mut conflicts,
594            KnowledgeSignalKind::LocaleMatch,
595            "locale",
596            &self.scope.locale,
597            &context.locale,
598        );
599        compare_optional_scope(
600            &mut signals,
601            &mut conflicts,
602            KnowledgeSignalKind::TenantMatch,
603            "tenant",
604            &self.scope.tenant_key,
605            &context.tenant_key,
606        );
607        if self.scope.browser_family == context.browser_family
608            && browser_version_matches(
609                self.scope.browser_version_range.as_deref(),
610                context.browser_version.as_deref(),
611            )
612        {
613            signals.push(signal(
614                KnowledgeSignalKind::BrowserMatch,
615                "browser scope matches",
616            ));
617        } else {
618            conflicts.push("browser family or version does not match".into());
619        }
620        if self.scope.glass_schema_version == context.glass_schema_version {
621            signals.push(signal(
622                KnowledgeSignalKind::SchemaMatch,
623                "schema scope matches",
624            ));
625        } else {
626            conflicts.push("Glass schema scope does not match".into());
627        }
628        if self.scope.policy_preset == context.policy_preset {
629            signals.push(signal(
630                KnowledgeSignalKind::PolicyMatch,
631                "policy scope matches",
632            ));
633        } else {
634            conflicts.push("policy scope does not match".into());
635        }
636        for required in &self.invalidation.required_landmarks {
637            if context.landmarks.iter().any(|current| current == required) {
638                signals.push(signal(
639                    KnowledgeSignalKind::LandmarkMatch,
640                    format!("landmark {required} is present"),
641                ));
642            } else {
643                missing_landmarks.push(required.clone());
644            }
645        }
646
647        let age_seconds =
648            parse_age_seconds(&self.source.last_verified_at, context.now_epoch_seconds);
649        if let Some(age) = age_seconds {
650            if self
651                .invalidation
652                .max_age_seconds
653                .is_none_or(|maximum| age <= maximum as i64)
654            {
655                signals.push(signal(
656                    KnowledgeSignalKind::FreshnessMatch,
657                    format!("record age is {age} seconds"),
658                ));
659            } else {
660                conflicts.push("record exceeded its maximum age".into());
661            }
662        } else {
663            conflicts.push("lastVerifiedAt is not a valid RFC3339 timestamp".into());
664        }
665
666        let scope_conflict = conflicts.iter().any(|conflict| {
667            matches!(
668                conflict.as_str(),
669                "origin does not match"
670                    | "path is outside the record pattern"
671                    | "profile scope does not match"
672                    | "locale does not match"
673                    | "tenant does not match"
674                    | "browser family or version does not match"
675                    | "Glass schema scope does not match"
676                    | "policy scope does not match"
677            )
678        });
679        let status = if self.confidence == KnowledgeConfidence::Contradicted {
680            KnowledgeAssessmentStatus::Contradicted
681        } else if self.confidence == KnowledgeConfidence::Quarantined {
682            KnowledgeAssessmentStatus::Quarantined
683        } else if scope_conflict {
684            KnowledgeAssessmentStatus::OutOfScope
685        } else if !missing_landmarks.is_empty()
686            || age_seconds.is_none()
687            || self
688                .invalidation
689                .max_age_seconds
690                .is_some_and(|maximum| age_seconds.is_some_and(|age| age > maximum as i64))
691        {
692            KnowledgeAssessmentStatus::Stale
693        } else {
694            KnowledgeAssessmentStatus::Eligible
695        };
696        KnowledgeAssessment {
697            record_id: self.record_id.clone(),
698            status,
699            signals,
700            conflicts,
701            missing_landmarks,
702            age_seconds,
703        }
704    }
705
706    /// Apply a lifecycle transition. Promotion to `verified` and recovery from
707    /// contradiction/quarantine require fresh verification evidence.
708    pub fn transition(
709        &mut self,
710        next: KnowledgeConfidence,
711        reason: String,
712        observed_at: String,
713        fresh_verification: bool,
714    ) -> Result<(), KnowledgeValidationError> {
715        validate_text("reason", &reason, MAX_SCOPE_VALUE_BYTES, false)?;
716        validate_text("observedAt", &observed_at, MAX_TIMESTAMP_BYTES, false)?;
717        validate_timestamp("observedAt", &observed_at)?;
718        if self.confidence == next {
719            if fresh_verification {
720                self.source.last_verified_at = observed_at;
721                self.source.verification_count = self.source.verification_count.saturating_add(1);
722                self.validate()?;
723            }
724            return Ok(());
725        }
726        let requires_fresh = matches!(
727            next,
728            KnowledgeConfidence::Verified | KnowledgeConfidence::Observed
729        ) || matches!(
730            self.confidence,
731            KnowledgeConfidence::Contradicted | KnowledgeConfidence::Quarantined
732        );
733        if requires_fresh && !fresh_verification {
734            return Err(KnowledgeValidationError::new(
735                "freshVerification",
736                "this lifecycle transition requires fresh browser verification",
737            ));
738        }
739        if self.history.len() >= MAX_HISTORY {
740            return Err(KnowledgeValidationError::new(
741                "history",
742                format!("cannot append beyond {MAX_HISTORY} events"),
743            ));
744        }
745        let event = KnowledgeLifecycleEvent {
746            from: self.confidence,
747            to: next,
748            reason,
749            observed_at: observed_at.clone(),
750        };
751        self.confidence = next;
752        if fresh_verification {
753            self.source.last_verified_at = observed_at;
754            self.source.verification_count = self.source.verification_count.saturating_add(1);
755        }
756        self.history.push(event);
757        self.validate()
758    }
759}
760
761fn signal(kind: KnowledgeSignalKind, detail: impl Into<String>) -> KnowledgeAssessmentSignal {
762    KnowledgeAssessmentSignal {
763        kind,
764        detail: detail.into(),
765    }
766}
767
768fn hash_knowledge_identity(parts: &[&str]) -> String {
769    let canonical = serde_json::to_vec(parts).expect("JSON string arrays are serializable");
770    let digest = Sha256::digest(canonical);
771    format!("sha256:{digest:x}")
772}
773
774fn compare_optional_scope(
775    signals: &mut Vec<KnowledgeAssessmentSignal>,
776    conflicts: &mut Vec<String>,
777    kind: KnowledgeSignalKind,
778    label: &str,
779    expected: &Option<String>,
780    actual: &Option<String>,
781) {
782    if expected
783        .as_ref()
784        .is_none_or(|expected| Some(expected) == actual.as_ref())
785    {
786        signals.push(signal(kind, format!("{label} scope matches")));
787    } else {
788        conflicts.push(format!("{label} does not match"));
789    }
790}
791
792fn browser_version_matches(expected_range: Option<&str>, actual: Option<&str>) -> bool {
793    match (expected_range, actual) {
794        (None, _) => true,
795        (Some(expected), Some(actual)) => {
796            if expected == actual || expected == ">=current" {
797                return true;
798            }
799            let (operator, expected_version) = [">=", "<=", ">", "<", "="]
800                .iter()
801                .find_map(|operator| {
802                    expected
803                        .strip_prefix(operator)
804                        .map(|version| (*operator, version))
805                })
806                .unwrap_or(("=", expected));
807            let Some(expected_major) = version_major(expected_version) else {
808                return false;
809            };
810            let Some(actual_major) = version_major(actual) else {
811                return false;
812            };
813            match operator {
814                ">=" => actual_major >= expected_major,
815                "<=" => actual_major <= expected_major,
816                ">" => actual_major > expected_major,
817                "<" => actual_major < expected_major,
818                "=" => actual_major == expected_major,
819                _ => false,
820            }
821        }
822        (Some(_), None) => false,
823    }
824}
825
826fn version_major(value: &str) -> Option<u64> {
827    value
828        .trim()
829        .split_once('.')
830        .map_or(value.trim(), |(major, _)| major)
831        .parse()
832        .ok()
833}
834
835fn parse_age_seconds(last_verified_at: &str, now_epoch_seconds: i64) -> Option<i64> {
836    let timestamp = chrono::DateTime::parse_from_rfc3339(last_verified_at).ok()?;
837    Some(
838        now_epoch_seconds
839            .saturating_sub(timestamp.timestamp())
840            .max(0),
841    )
842}
843
844fn path_matches(pattern: &str, path: &str) -> bool {
845    if pattern == path {
846        return true;
847    }
848    let Some(prefix) = pattern.strip_suffix('*') else {
849        return false;
850    };
851    path.starts_with(prefix)
852}
853
854/// The persisted top-level store document.
855#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
856#[serde(rename_all = "camelCase")]
857pub struct KnowledgeStoreSnapshot {
858    pub schema_version: u32,
859    pub records: Vec<KnowledgeRecord>,
860}
861
862impl KnowledgeStoreSnapshot {
863    pub fn validate(&self) -> Result<(), KnowledgeValidationError> {
864        if self.schema_version != KNOWLEDGE_SCHEMA_VERSION {
865            return Err(KnowledgeValidationError::new(
866                "schemaVersion",
867                format!(
868                    "unsupported schema version {}; expected {}",
869                    self.schema_version, KNOWLEDGE_SCHEMA_VERSION
870                ),
871            ));
872        }
873        if self.records.len() > MAX_KNOWLEDGE_RECORDS {
874            return Err(KnowledgeValidationError::new(
875                "records",
876                format!("contains more than {MAX_KNOWLEDGE_RECORDS} records"),
877            ));
878        }
879        let mut record_ids = BTreeSet::new();
880        for (index, record) in self.records.iter().enumerate() {
881            record.validate().map_err(|error| error.at(index))?;
882            if !record_ids.insert(record.record_id.as_str()) {
883                return Err(KnowledgeValidationError::new(
884                    format!("records[{index}].recordId"),
885                    "record ID is duplicated",
886                ));
887            }
888        }
889        Ok(())
890    }
891
892    pub fn to_canonical_json(&self) -> Result<String, KnowledgeValidationError> {
893        self.validate()?;
894        serde_json::to_string(self)
895            .map_err(|error| KnowledgeValidationError::new("$", error.to_string()))
896    }
897}
898
899fn validate_scope(scope: &KnowledgeScope) -> Result<(), KnowledgeValidationError> {
900    validate_text("scope.origin", &scope.origin, 2048, false)?;
901    validate_text("scope.pathPattern", &scope.path_pattern, 512, false)?;
902    validate_text(
903        "scope.browserFamily",
904        &scope.browser_family,
905        MAX_SCOPE_VALUE_BYTES,
906        false,
907    )?;
908    validate_text(
909        "scope.policyPreset",
910        &scope.policy_preset,
911        MAX_SCOPE_VALUE_BYTES,
912        false,
913    )?;
914    if scope.glass_schema_version == 0 {
915        return Err(KnowledgeValidationError::new(
916            "scope.glassSchemaVersion",
917            "must be positive",
918        ));
919    }
920    for (path, value) in [
921        ("scope.profileKey", scope.profile_key.as_deref()),
922        ("scope.locale", scope.locale.as_deref()),
923        ("scope.tenantKey", scope.tenant_key.as_deref()),
924        (
925            "scope.browserVersionRange",
926            scope.browser_version_range.as_deref(),
927        ),
928    ] {
929        if let Some(value) = value {
930            validate_text(path, value, MAX_SCOPE_VALUE_BYTES, false)?;
931        }
932    }
933    match scope.profile_scope {
934        KnowledgeProfileScope::ProfileBound if scope.profile_key.is_none() => {
935            Err(KnowledgeValidationError::new(
936                "scope.profileKey",
937                "is required for profileBound knowledge",
938            ))
939        }
940        KnowledgeProfileScope::Anonymous if scope.profile_key.is_some() => {
941            Err(KnowledgeValidationError::new(
942                "scope.profileKey",
943                "must be absent for anonymous knowledge",
944            ))
945        }
946        _ => Ok(()),
947    }
948}
949
950fn validate_source(source: &KnowledgeSource) -> Result<(), KnowledgeValidationError> {
951    validate_timestamp("source.firstSeenAt", &source.first_seen_at)?;
952    validate_timestamp("source.lastVerifiedAt", &source.last_verified_at)?;
953    validate_text(
954        "source.glassVersion",
955        &source.glass_version,
956        MAX_SCOPE_VALUE_BYTES,
957        false,
958    )
959}
960
961fn validate_timestamp(path: &str, value: &str) -> Result<(), KnowledgeValidationError> {
962    validate_text(path, value, MAX_TIMESTAMP_BYTES, false)?;
963    chrono::DateTime::parse_from_rfc3339(value).map_err(|error| {
964        KnowledgeValidationError::new(path, format!("must be RFC3339: {error}"))
965    })?;
966    Ok(())
967}
968
969fn validate_invalidation(
970    invalidation: &KnowledgeInvalidation,
971) -> Result<(), KnowledgeValidationError> {
972    if invalidation.required_landmarks.len() > MAX_LANDMARKS {
973        return Err(KnowledgeValidationError::new(
974            "invalidation.requiredLandmarks",
975            format!("contains more than {MAX_LANDMARKS} landmarks"),
976        ));
977    }
978    let mut landmarks = BTreeSet::new();
979    for (index, landmark) in invalidation.required_landmarks.iter().enumerate() {
980        validate_text(
981            &format!("invalidation.requiredLandmarks[{index}]"),
982            landmark,
983            MAX_SCOPE_VALUE_BYTES,
984            false,
985        )?;
986        if !landmarks.insert(landmark) {
987            return Err(KnowledgeValidationError::new(
988                format!("invalidation.requiredLandmarks[{index}]"),
989                "landmark is duplicated",
990            ));
991        }
992    }
993    Ok(())
994}
995
996fn validate_json_value(
997    path: &str,
998    value: &Value,
999    depth: usize,
1000) -> Result<(), KnowledgeValidationError> {
1001    if depth > MAX_JSON_DEPTH {
1002        return Err(KnowledgeValidationError::new(
1003            path,
1004            format!("exceeds the {MAX_JSON_DEPTH}-level nesting limit"),
1005        ));
1006    }
1007    match value {
1008        Value::Null | Value::Bool(_) | Value::Number(_) => Ok(()),
1009        Value::String(value) => validate_text(path, value, MAX_JSON_STRING_BYTES, true),
1010        Value::Array(values) => {
1011            if values.len() > MAX_JSON_ARRAY_ENTRIES {
1012                return Err(KnowledgeValidationError::new(
1013                    path,
1014                    format!("contains more than {MAX_JSON_ARRAY_ENTRIES} values"),
1015                ));
1016            }
1017            for (index, value) in values.iter().enumerate() {
1018                validate_json_value(&format!("{path}[{index}]"), value, depth + 1)?;
1019            }
1020            Ok(())
1021        }
1022        Value::Object(values) => {
1023            if values.len() > MAX_JSON_OBJECT_ENTRIES {
1024                return Err(KnowledgeValidationError::new(
1025                    path,
1026                    format!("contains more than {MAX_JSON_OBJECT_ENTRIES} fields"),
1027                ));
1028            }
1029            for (key, value) in values {
1030                validate_key(&format!("{path}.{key}"), key)?;
1031                validate_json_value(&format!("{path}.{key}"), value, depth + 1)?;
1032            }
1033            Ok(())
1034        }
1035    }
1036}
1037
1038fn validate_key(path: &str, key: &str) -> Result<(), KnowledgeValidationError> {
1039    validate_text(path, key, MAX_SCOPE_VALUE_BYTES, false)?;
1040    let normalized = key
1041        .chars()
1042        .filter(char::is_ascii_alphanumeric)
1043        .collect::<String>()
1044        .to_ascii_lowercase();
1045    const FORBIDDEN: &[&str] = &[
1046        "authorization",
1047        "cookie",
1048        "credential",
1049        "formvalue",
1050        "password",
1051        "rawaccessibility",
1052        "rawcdp",
1053        "rawdom",
1054        "secret",
1055        "screenshot",
1056        "token",
1057    ];
1058    if FORBIDDEN.iter().any(|word| normalized.contains(word)) {
1059        return Err(KnowledgeValidationError::new(
1060            path,
1061            "field name is not permitted in persistent knowledge",
1062        ));
1063    }
1064    Ok(())
1065}
1066
1067fn validate_text(
1068    path: &str,
1069    value: &str,
1070    maximum: usize,
1071    allow_empty: bool,
1072) -> Result<(), KnowledgeValidationError> {
1073    if (!allow_empty && value.is_empty()) || value.len() > maximum {
1074        let requirement = if allow_empty {
1075            format!("at most {maximum} bytes")
1076        } else {
1077            format!("non-empty and at most {maximum} bytes")
1078        };
1079        return Err(KnowledgeValidationError::new(
1080            path,
1081            format!("must be {requirement}"),
1082        ));
1083    }
1084    Ok(())
1085}
1086
1087/// Path-aware knowledge contract validation failure.
1088#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
1089pub struct KnowledgeValidationError {
1090    pub path: String,
1091    pub reason: String,
1092}
1093
1094impl KnowledgeValidationError {
1095    fn new(path: impl Into<String>, reason: impl Into<String>) -> Self {
1096        Self {
1097            path: path.into(),
1098            reason: reason.into(),
1099        }
1100    }
1101
1102    fn at(self, index: usize) -> Self {
1103        Self::new(format!("records[{index}].{}", self.path), self.reason)
1104    }
1105}
1106
1107impl fmt::Display for KnowledgeValidationError {
1108    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
1109        write!(formatter, "{}: {}", self.path, self.reason)
1110    }
1111}
1112
1113impl std::error::Error for KnowledgeValidationError {}
1114
1115#[cfg(test)]
1116mod tests {
1117    use super::*;
1118    use crate::browser::session::{
1119        FingerprintInvalidation, IntentConfidence, SemanticIntentPurpose, SemanticRegionKind,
1120        SemanticRouteIdentity, SemanticTargetFingerprint, WorkflowDefinition,
1121    };
1122    use serde_json::json;
1123
1124    fn record() -> KnowledgeRecord {
1125        KnowledgeRecord {
1126            schema_version: KNOWLEDGE_SCHEMA_VERSION,
1127            record_id: "knowledge_docs_1".into(),
1128            kind: KnowledgeRecordKind::PageFamily,
1129            scope: KnowledgeScope {
1130                origin: "https://example.test".into(),
1131                path_pattern: "/docs/*".into(),
1132                profile_scope: KnowledgeProfileScope::Anonymous,
1133                profile_key: None,
1134                locale: Some("en-US".into()),
1135                tenant_key: None,
1136                browser_family: "chromium".into(),
1137                browser_version_range: Some(">=120".into()),
1138                glass_schema_version: 1,
1139                policy_preset: "balanced".into(),
1140            },
1141            source: KnowledgeSource {
1142                first_seen_at: "2026-07-27T00:00:00Z".into(),
1143                last_verified_at: "2026-07-27T00:00:00Z".into(),
1144                glass_version: "0.2.0".into(),
1145                verification_count: 1,
1146            },
1147            confidence: KnowledgeConfidence::Observed,
1148            invalidation: KnowledgeInvalidation {
1149                max_age_seconds: Some(604_800),
1150                required_landmarks: vec!["main".into(), "search".into()],
1151            },
1152            data: json!({"pageKind": "documentation", "regions": ["main", "search"]}),
1153            history: Vec::new(),
1154        }
1155    }
1156
1157    #[test]
1158    fn record_round_trip_and_hash_are_deterministic() {
1159        let record = record();
1160        let canonical = record.to_canonical_json().unwrap();
1161        let parsed: KnowledgeRecord = serde_json::from_str(&canonical).unwrap();
1162        assert_eq!(parsed, record);
1163        assert_eq!(
1164            record.content_hash().unwrap(),
1165            parsed.content_hash().unwrap()
1166        );
1167    }
1168
1169    #[test]
1170    fn sensitive_data_keys_are_rejected() {
1171        let mut record = record();
1172        record.data = json!({"requiredLandmarks": ["main"], "password": "never"});
1173        let error = record.validate().unwrap_err();
1174        assert_eq!(error.path, "data.password");
1175    }
1176
1177    #[test]
1178    fn scalar_record_data_is_rejected_by_the_contract() {
1179        let mut record = record();
1180        record.data = json!("not-a-knowledge-object");
1181        let error = record.validate().unwrap_err();
1182        assert_eq!(error.path, "data");
1183    }
1184
1185    #[test]
1186    fn profile_bound_scope_requires_a_profile_key() {
1187        let mut record = record();
1188        record.scope.profile_scope = KnowledgeProfileScope::ProfileBound;
1189        let error = record.validate().unwrap_err();
1190        assert_eq!(error.path, "scope.profileKey");
1191    }
1192
1193    #[test]
1194    fn verified_promotion_requires_fresh_evidence() {
1195        let mut record = record();
1196        let error = record
1197            .transition(
1198                KnowledgeConfidence::Verified,
1199                "imported record".into(),
1200                "2026-07-27T00:00:01Z".into(),
1201                false,
1202            )
1203            .unwrap_err();
1204        assert_eq!(error.path, "freshVerification");
1205        record
1206            .transition(
1207                KnowledgeConfidence::Verified,
1208                "fresh landmark match".into(),
1209                "2026-07-27T00:00:01Z".into(),
1210                true,
1211            )
1212            .unwrap();
1213        assert_eq!(record.confidence, KnowledgeConfidence::Verified);
1214        assert_eq!(record.source.last_verified_at, "2026-07-27T00:00:01Z");
1215        assert_eq!(record.source.verification_count, 2);
1216        assert_eq!(record.history.len(), 1);
1217    }
1218
1219    #[test]
1220    fn fresh_verification_refreshes_an_unchanged_state() {
1221        let mut record = record();
1222        record
1223            .transition(
1224                KnowledgeConfidence::Observed,
1225                "fresh observation repeated".into(),
1226                "2026-07-27T00:00:02Z".into(),
1227                true,
1228            )
1229            .unwrap();
1230        assert_eq!(record.source.last_verified_at, "2026-07-27T00:00:02Z");
1231        assert_eq!(record.source.verification_count, 2);
1232        assert!(record.history.is_empty());
1233    }
1234
1235    #[test]
1236    fn snapshot_rejects_duplicate_record_ids() {
1237        let record = record();
1238        let snapshot = KnowledgeStoreSnapshot {
1239            schema_version: KNOWLEDGE_SCHEMA_VERSION,
1240            records: vec![record.clone(), record],
1241        };
1242        let error = snapshot.validate().unwrap_err();
1243        assert_eq!(error.path, "records[1].recordId");
1244    }
1245
1246    fn lookup_context() -> KnowledgeLookupContext {
1247        KnowledgeLookupContext {
1248            origin: "https://example.test".into(),
1249            path: "/docs/getting-started".into(),
1250            profile_scope: KnowledgeProfileScope::Anonymous,
1251            profile_key: None,
1252            locale: Some("en-US".into()),
1253            tenant_key: None,
1254            browser_family: "chromium".into(),
1255            browser_version: Some(">=120".into()),
1256            glass_schema_version: 1,
1257            policy_preset: "balanced".into(),
1258            landmarks: vec!["documentation".into(), "main".into(), "search".into()],
1259            now_epoch_seconds: chrono::DateTime::parse_from_rfc3339("2026-07-27T00:00:00Z")
1260                .unwrap()
1261                .timestamp(),
1262        }
1263    }
1264
1265    #[test]
1266    fn assessment_accepts_fresh_matching_scope_and_landmarks() {
1267        let assessment = record().assess(&lookup_context());
1268        assert_eq!(assessment.status, KnowledgeAssessmentStatus::Eligible);
1269        assert_eq!(assessment.missing_landmarks, Vec::<String>::new());
1270        assert!(assessment.conflicts.is_empty());
1271    }
1272
1273    #[test]
1274    fn assessment_matches_browser_version_ranges() {
1275        assert!(browser_version_matches(Some(">=120"), Some("120.0")));
1276        assert!(browser_version_matches(Some("<121"), Some("120.0.1")));
1277        assert!(!browser_version_matches(Some(">=121"), Some("120.0")));
1278        assert!(!browser_version_matches(Some("120"), None));
1279    }
1280
1281    #[test]
1282    fn assessment_marks_missing_landmarks_stale() {
1283        let mut context = lookup_context();
1284        context.landmarks.retain(|landmark| landmark != "search");
1285        let assessment = record().assess(&context);
1286        assert_eq!(assessment.status, KnowledgeAssessmentStatus::Stale);
1287        assert_eq!(assessment.missing_landmarks, vec!["search"]);
1288    }
1289
1290    #[test]
1291    fn assessment_rejects_cross_origin_scope() {
1292        let mut context = lookup_context();
1293        context.origin = "https://other.test".into();
1294        let assessment = record().assess(&context);
1295        assert_eq!(assessment.status, KnowledgeAssessmentStatus::OutOfScope);
1296        assert!(
1297            assessment
1298                .conflicts
1299                .contains(&"origin does not match".to_string())
1300        );
1301    }
1302
1303    #[test]
1304    fn page_record_keeps_shape_but_not_current_targets() {
1305        let corpus: Value = serde_json::from_str(include_str!(
1306            "../../../benchmarks/scenarios/semantic-observation-v1.json"
1307        ))
1308        .unwrap();
1309        let observation =
1310            SemanticObservation::from_json(&corpus["fixtures"][1]["observation"].to_string())
1311                .unwrap();
1312        let record = KnowledgeRecord::from_page_observation(
1313            &observation,
1314            KnowledgeRecordBuildOptions {
1315                record_id: "knowledge_search".into(),
1316                scope: record().scope,
1317                glass_version: "0.2.0".into(),
1318                observed_at: "2026-07-27T00:00:00Z".into(),
1319            },
1320        )
1321        .unwrap();
1322        let data = serde_json::to_string(&record.data).unwrap();
1323        assert!(data.contains("searchResults"));
1324        assert!(!data.contains("axr-8-9"));
1325        assert_eq!(record.confidence, KnowledgeConfidence::Observed);
1326    }
1327
1328    #[test]
1329    fn target_record_keeps_digest_but_not_current_handles_or_names() {
1330        let candidate = SemanticIntentCandidate {
1331            id: "candidate_1".into(),
1332            reference: "axr-42-1".into(),
1333            role: "button".into(),
1334            name: "Private Settings Label".into(),
1335            input_type: None,
1336            region_id: Some("region_navigation".into()),
1337            region_kind: Some(SemanticRegionKind::Navigation),
1338            confidence: IntentConfidence::Exact,
1339            evidence: Vec::new(),
1340            fingerprint: Some(SemanticTargetFingerprint {
1341                revision: 42,
1342                route: SemanticRouteIdentity {
1343                    target_id: "target-1".into(),
1344                    frame_id: "frame-1".into(),
1345                    url: "https://example.test/settings".into(),
1346                },
1347                role: "button".into(),
1348                name: "Private Settings Label".into(),
1349                input_type: None,
1350                region_id: Some("region_navigation".into()),
1351                region_kind: Some(SemanticRegionKind::Navigation),
1352                purpose: SemanticIntentPurpose::Open,
1353                invalidated_by: vec![FingerprintInvalidation::Revision],
1354            }),
1355        };
1356        let record = KnowledgeRecord::from_intent_candidate(
1357            &candidate,
1358            KnowledgeRecordBuildOptions {
1359                record_id: "knowledge-settings-target".into(),
1360                scope: record().scope,
1361                glass_version: "0.2.0".into(),
1362                observed_at: "2026-07-27T00:00:00Z".into(),
1363            },
1364        )
1365        .unwrap();
1366        let data = serde_json::to_string(&record.data).unwrap();
1367        assert!(data.contains("sha256:"));
1368        assert!(!data.contains("axr-42-1"));
1369        assert!(!data.contains("Private Settings Label"));
1370        assert!(!data.contains("target-1"));
1371        assert!(!data.contains("frame-1"));
1372    }
1373
1374    #[test]
1375    fn workflow_record_keeps_shape_without_definition_details() {
1376        let corpus: Value = serde_json::from_str(include_str!(
1377            "../../../benchmarks/scenarios/workflow-v1.json"
1378        ))
1379        .unwrap();
1380        let workflow =
1381            WorkflowDefinition::from_value(corpus["scenarios"][0]["workflow"].clone()).unwrap();
1382        let record = KnowledgeRecord::from_workflow_definition(
1383            &workflow,
1384            KnowledgeRecordBuildOptions {
1385                record_id: "knowledge-workflow-entry".into(),
1386                scope: record().scope,
1387                glass_version: "0.2.0".into(),
1388                observed_at: "2026-07-27T00:00:00Z".into(),
1389            },
1390        )
1391        .unwrap();
1392        assert_eq!(record.kind, KnowledgeRecordKind::WorkflowEntryPoint);
1393        assert_eq!(record.confidence, KnowledgeConfidence::Candidate);
1394        let data = serde_json::to_string(&record.data).unwrap();
1395        assert!(data.contains("workflowHash"));
1396        assert!(!data.contains("linear-typed-output"));
1397        assert!(!data.contains("Glass Scorecard"));
1398        assert!(!data.contains("target"));
1399    }
1400}