1use 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#[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#[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#[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 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(®ion.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#[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#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
136#[serde(rename_all = "camelCase", deny_unknown_fields)]
137pub struct KnowledgeAssessmentSignal {
138 pub kind: KnowledgeSignalKind,
139 pub detail: String,
140}
141
142#[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#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
156#[serde(rename_all = "camelCase", deny_unknown_fields)]
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#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
172#[serde(rename_all = "camelCase")]
173pub enum KnowledgeObservationMode {
174 FreshOnly,
175 Assessed,
176}
177
178#[derive(Debug, Clone, Serialize, Deserialize)]
181#[serde(rename_all = "camelCase", deny_unknown_fields)]
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#[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#[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#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
223#[serde(rename_all = "camelCase", deny_unknown_fields)]
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#[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#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
253#[serde(rename_all = "camelCase", deny_unknown_fields)]
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#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
263#[serde(rename_all = "camelCase", deny_unknown_fields)]
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#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
273#[serde(rename_all = "camelCase", deny_unknown_fields)]
274pub struct KnowledgeLifecycleEvent {
275 pub from: KnowledgeConfidence,
276 pub to: KnowledgeConfidence,
277 pub reason: String,
278 pub observed_at: String,
279}
280
281#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
284#[serde(rename_all = "camelCase", deny_unknown_fields)]
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 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 ®ion_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 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 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 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 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 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 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 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#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
856#[serde(rename_all = "camelCase", deny_unknown_fields)]
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#[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}