agent_protocols/knowledge/
mod.rs1mod store;
10mod types;
11pub use store::*;
12pub use types::*;
13
14use crate::identity::{self, AgentId, Envelope, Event};
15use crate::{Result, SdkError};
16use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _};
17use serde::Serialize;
18use serde_json::{json, Value};
19use sha3::{Digest, Sha3_256};
20use std::collections::{BTreeMap, BTreeSet};
21use std::sync::{Mutex, OnceLock};
22
23pub const PROTOCOL: &str = "agent-knowledge/1.0";
24pub const SCHEMA_JSON: &str = include_str!("schema.json");
25pub const EVENT_TYPES: [&str; 3] = ["knowledge.publish", "knowledge.assess", "knowledge.retract"];
26pub const SEARCH_MODES: [&str; 3] = ["lexical", "semantic", "hybrid"];
27pub const KNOWLEDGE_ERROR_CODES: [&str; 6] = [
28 "missing_dependency",
29 "invalid_target",
30 "invalid_cursor",
31 "query_too_broad",
32 "query_unavailable",
33 "unsupported_search_mode",
34];
35pub const DEFAULT_FUTURE_SKEW_MS: i64 = 300_000;
37pub const DEFAULT_MAX_ENVELOPE_BYTES: usize = 262_144;
38const ASSESSMENT_RELATIONS: [&str; 3] = ["derived_from", "supports", "contradicts"];
40
41pub(crate) fn fail(code: &'static str, message: impl Into<String>) -> SdkError {
42 SdkError::protocol(code, message)
43}
44pub(crate) fn arr(v: &Value) -> &[Value] {
45 v.as_array().map(Vec::as_slice).unwrap_or(&[])
46}
47pub(crate) fn string(v: &Value) -> &str {
48 v.as_str().unwrap_or("")
49}
50
51pub(crate) trait KnowledgeInteger {
54 fn knowledge_u64(&self) -> Option<u64>;
55 fn knowledge_i64(&self) -> Option<i64>;
56}
57impl KnowledgeInteger for Value {
58 fn knowledge_u64(&self) -> Option<u64> {
59 self.as_u64()
60 .filter(|n| *n <= identity::MAX_SAFE_NONCE)
61 .or_else(|| {
62 self.as_f64()
63 .filter(|n| {
64 n.is_finite()
65 && n.fract() == 0.0
66 && *n >= 0.0
67 && *n <= identity::MAX_SAFE_NONCE as f64
68 })
69 .map(|n| n as u64)
70 })
71 }
72 fn knowledge_i64(&self) -> Option<i64> {
73 self.as_i64()
74 .filter(|n| n.unsigned_abs() <= identity::MAX_SAFE_NONCE)
75 .or_else(|| {
76 self.as_f64()
77 .filter(|n| {
78 n.is_finite()
79 && n.fract() == 0.0
80 && n.abs() <= identity::MAX_SAFE_NONCE as f64
81 })
82 .map(|n| n as i64)
83 })
84 }
85}
86pub(crate) fn normalized_json(value: &Value) -> Value {
87 match value {
88 Value::Number(_) => value
89 .knowledge_i64()
90 .map(Value::from)
91 .unwrap_or_else(|| value.clone()),
92 Value::Array(items) => Value::Array(items.iter().map(normalized_json).collect()),
93 Value::Object(object) => Value::Object(
94 object
95 .iter()
96 .map(|(k, v)| (k.clone(), normalized_json(v)))
97 .collect(),
98 ),
99 _ => value.clone(),
100 }
101}
102
103pub fn validate_knowledge_schema(value: &Value, definition: &str) -> Result<()> {
105 static VALIDATORS: OnceLock<Mutex<BTreeMap<String, jsonschema::Validator>>> = OnceLock::new();
106 let mut validators = VALIDATORS
107 .get_or_init(|| Mutex::new(BTreeMap::new()))
108 .lock()
109 .map_err(|_| fail("invalid_event", "schema cache unavailable"))?;
110 if !validators.contains_key(definition) {
111 let schema: Value = serde_json::from_str(SCHEMA_JSON)?;
112 if schema["$defs"].get(definition).is_none() {
113 return Err(fail("invalid_event", "unknown schema definition"));
114 }
115 let validator = jsonschema::options()
116 .with_draft(jsonschema::Draft::Draft202012)
117 .should_validate_formats(true)
118 .build(&json!({"$ref": format!("#/$defs/{definition}"), "$defs": schema["$defs"]}))
119 .map_err(|e| fail("invalid_event", e.to_string()))?;
120 validators.insert(definition.to_owned(), validator);
121 }
122 validators[definition]
123 .validate(value)
124 .map_err(|e| fail("invalid_event", e.to_string()))
125}
126
127pub fn parse_knowledge_envelope(text: &str) -> Result<Value> {
129 let value = identity::parse_strict_json(text)?;
130 validate_knowledge_envelope(&value)?;
131 Ok(value)
132}
133
134pub fn parse_knowledge_read_json(text: &str) -> Result<Value> {
135 identity::parse_strict_json(text).map_err(|e| fail("invalid_request", e.to_string()))
136}
137
138pub fn validate_knowledge_id(value: &str) -> Result<()> {
140 let bytes = URL_SAFE_NO_PAD
141 .decode(value)
142 .map_err(|_| fail("invalid_event", "invalid ID encoding"))?;
143 if bytes.len() != 32 || URL_SAFE_NO_PAD.encode(&bytes) != value {
144 return Err(fail("invalid_event", "ID must canonically encode 32 bytes"));
145 }
146 Ok(())
147}
148
149pub(crate) fn https_url(value: &str) -> Result<url::Url> {
150 let parsed = url::Url::parse(value).map_err(|_| fail("invalid_event", "invalid HTTPS URL"))?;
151 let authority = value
152 .strip_prefix("https://")
153 .unwrap_or("")
154 .split(['/', '?', '#'])
155 .next()
156 .unwrap_or("");
157 if parsed.scheme() != "https"
158 || parsed.host_str().is_none()
159 || authority.is_empty()
160 || authority.contains('@')
161 || value.contains('\\')
162 || value.chars().any(|c| c <= ' ')
163 {
164 return Err(fail(
165 "invalid_event",
166 "URL must be absolute HTTPS with host and no userinfo",
167 ));
168 }
169 Ok(parsed)
170}
171
172pub(crate) fn sha3_id(bytes: &[u8]) -> String {
173 URL_SAFE_NO_PAD.encode(Sha3_256::digest(bytes))
174}
175
176pub fn validate_knowledge_envelope(value: &Value) -> Result<Envelope<Value>> {
179 identity::parse_strict_json(&serde_json::to_string(value)?)?;
180 validate_knowledge_schema(value, "signedEnvelope")?;
181 let envelope: Envelope<Value> = serde_json::from_value(normalized_json(value))
182 .map_err(|e| fail("invalid_event", e.to_string()))?;
183 identity::verify_envelope(&envelope)?;
184 validate_knowledge_id(&envelope.hash)?;
185 let p = &envelope.event.payload;
186 if envelope.event.kind != "knowledge.retract" {
187 https_url(string(&p["license"]))?;
188 }
189 for evidence in arr(&p["evidence"]) {
190 https_url(string(&evidence["url"]))?;
191 if let Some(digest) = evidence.get("digest") {
192 validate_knowledge_id(string(digest))?;
193 }
194 }
195 let mut profiles = BTreeSet::new();
196 for binding in arr(&p["profiles"]) {
197 https_url(string(&binding["profile"]["url"]))?;
198 let digest = string(&binding["profile"]["digest"]);
199 validate_knowledge_id(digest)?;
200 if !profiles.insert(digest) {
201 return Err(fail("invalid_event", "duplicate profile digest"));
202 }
203 }
204 for target in knowledge_dependencies(value) {
205 validate_knowledge_id(&target)?;
206 }
207 Ok(envelope)
208}
209
210pub fn knowledge_dependencies(value: &Value) -> Vec<String> {
212 let p = &value["event"]["payload"];
213 if value["event"]["type"] == "knowledge.publish" {
214 arr(&p["relations"])
215 .iter()
216 .map(|r| string(&r["target"]).to_owned())
217 .collect::<BTreeSet<_>>()
218 .into_iter()
219 .collect()
220 } else {
221 vec![string(&p["target"]).to_owned()]
222 }
223}
224
225pub fn validate_knowledge_dependencies(
227 value: &Value,
228 retained: &BTreeMap<String, Value>,
229) -> Result<()> {
230 check_dependencies(value, |id| retained.get(id))
231}
232
233pub(crate) fn check_dependencies<'a>(
234 value: &Value,
235 lookup: impl Fn(&str) -> Option<&'a Value>,
236) -> Result<()> {
237 let missing: Vec<_> = knowledge_dependencies(value)
238 .into_iter()
239 .filter(|id| lookup(id).is_none())
240 .collect();
241 if !missing.is_empty() {
242 return Err(SdkError::protocol_with_data(
243 "missing_dependency",
244 "unresolved dependencies",
245 json!({"missing": missing}),
246 ));
247 }
248 let event = &value["event"];
249 let links = if event["type"] == "knowledge.publish" {
250 arr(&event["payload"]["relations"]).to_vec()
251 } else {
252 vec![json!({"relation": event["type"], "target": event["payload"]["target"]})]
253 };
254 for link in links {
255 let target = &lookup(string(&link["target"])).expect("resolved above")["event"];
256 let relation = string(&link["relation"]);
257 let allowed = target["type"] == "knowledge.publish"
258 || (target["type"] == "knowledge.assess"
259 && (relation == "knowledge.retract" || ASSESSMENT_RELATIONS.contains(&relation)));
260 if !allowed
261 || (relation == "addresses" && target["payload"]["kind"] != "question")
262 || (relation == "tests" && target["payload"]["kind"] != "hypothesis")
263 {
264 return Err(fail(
265 "invalid_target",
266 "wrong target type or contribution kind",
267 ));
268 }
269 if matches!(relation, "supersedes" | "knowledge.retract")
270 && (target["actor"] != event["actor"]
271 || target["nonce"].knowledge_u64() >= event["nonce"].knowledge_u64())
272 {
273 return Err(fail(
274 "invalid_target",
275 "author must match and target nonce must be smaller",
276 ));
277 }
278 }
279 Ok(())
280}
281
282pub fn materialize_knowledge(retained: &BTreeMap<String, Value>) -> Result<Value> {
284 for (id, item) in retained {
285 validate_knowledge_envelope(item)?;
286 if string(&item["hash"]) != id {
287 return Err(fail("invalid_event", "known-set key differs from hash"));
288 }
289 }
290 for item in retained.values() {
291 validate_knowledge_dependencies(item, retained)?;
292 }
293 let withdrawn: BTreeSet<&str> = retained
294 .values()
295 .filter(|v| v["event"]["type"] == "knowledge.retract")
296 .map(|v| string(&v["event"]["payload"]["target"]))
297 .collect();
298 let mut successors: BTreeMap<&str, BTreeSet<&str>> = BTreeMap::new();
299 let mut assessments: BTreeMap<&str, BTreeSet<&str>> = BTreeMap::new();
300 for (id, item) in retained {
301 let event = &item["event"];
302 if event["type"] == "knowledge.publish" {
303 for link in arr(&event["payload"]["relations"]) {
304 if link["relation"] == "supersedes" {
305 successors
306 .entry(string(&link["target"]))
307 .or_default()
308 .insert(id);
309 }
310 }
311 } else if event["type"] == "knowledge.assess" {
312 assessments
313 .entry(string(&event["payload"]["target"]))
314 .or_default()
315 .insert(id);
316 }
317 }
318 let mut result = serde_json::Map::new();
319 for (id, item) in retained {
320 if item["event"]["type"] == "knowledge.retract" {
321 continue;
322 }
323 let mut view =
324 json!({"status": if withdrawn.contains(id.as_str()) { "retracted" } else { "active" }});
325 if item["event"]["type"] == "knowledge.publish" {
326 let reports = assessments.get(id.as_str()).cloned().unwrap_or_default();
327 view["active_assessments"] = json!(reports
328 .iter()
329 .filter(|id| !withdrawn.contains(**id))
330 .collect::<Vec<_>>());
331 view["assessments"] = json!(reports);
332 view["successors"] = json!(successors.get(id.as_str()).cloned().unwrap_or_default());
333 }
334 result.insert(id.clone(), view);
335 }
336 Ok(Value::Object(result))
337}
338
339pub fn knowledge_publish_event(
340 actor: AgentId,
341 created_at: i64,
342 nonce: u64,
343 payload: KnowledgePublishPayload,
344) -> Event<KnowledgePublishPayload> {
345 Event::new(PROTOCOL, EVENT_TYPES[0], actor, created_at, nonce, payload)
346}
347pub fn knowledge_assess_event(
348 actor: AgentId,
349 created_at: i64,
350 nonce: u64,
351 payload: KnowledgeAssessPayload,
352) -> Event<KnowledgeAssessPayload> {
353 Event::new(PROTOCOL, EVENT_TYPES[1], actor, created_at, nonce, payload)
354}
355pub fn knowledge_retract_event(
356 actor: AgentId,
357 created_at: i64,
358 nonce: u64,
359 payload: KnowledgeRetractPayload,
360) -> Event<KnowledgeRetractPayload> {
361 Event::new(PROTOCOL, EVENT_TYPES[2], actor, created_at, nonce, payload)
362}
363
364pub fn validate_typed_knowledge_envelope<P: Serialize>(envelope: &Envelope<P>) -> Result<()> {
365 validate_knowledge_envelope(&serde_json::to_value(envelope)?).map(|_| ())
366}
367
368pub fn verify_knowledge_evidence(
372 digest: Option<&str>,
373 bytes: Option<&[u8]>,
374) -> Result<EvidenceStatus> {
375 let Some(digest) = digest else {
376 return Ok(EvidenceStatus::Unchecked);
377 };
378 validate_knowledge_id(digest)?;
379 Ok(match bytes {
380 None => EvidenceStatus::Unavailable,
381 Some(bytes) if sha3_id(bytes) == digest => EvidenceStatus::Matched,
382 Some(_) => EvidenceStatus::Mismatched,
383 })
384}
385
386pub fn knowledge_text_terms(text: &str, lexical: bool) -> Result<Vec<String>> {
388 if !(1..=1024).contains(&text.chars().count()) {
389 return Err(fail(
390 "invalid_request",
391 "text needs 1..1024 Unicode scalars",
392 ));
393 }
394 let terms: Vec<_> = text
395 .split(|c: char| matches!(c, '\u{9}'..='\u{d}' | ' '))
396 .filter(|p| !p.is_empty())
397 .map(str::to_ascii_lowercase)
398 .collect();
399 if terms.is_empty() || (lexical && terms.len() > 16) {
400 return Err(fail("invalid_request", "empty text or too many terms"));
401 }
402 Ok(terms)
403}
404
405pub fn knowledge_text_matches(item: &Value, text: &str) -> Result<bool> {
406 let p = &item["event"]["payload"];
407 let names: &[&str] = match string(&item["event"]["type"]) {
408 "knowledge.publish" => &["title", "statement", "basis"],
409 "knowledge.assess" => &["summary", "basis"],
410 _ => &["reason"],
411 };
412 let mut fields: Vec<String> = names
413 .iter()
414 .map(|name| string(&p[*name]).to_ascii_lowercase())
415 .collect();
416 if let Some(context) = p["context"].as_object() {
417 for value in context.values() {
418 if let Some(s) = value.as_str() {
419 fields.push(s.to_ascii_lowercase());
420 } else {
421 fields.extend(arr(value).iter().map(|v| string(v).to_ascii_lowercase()));
422 }
423 }
424 }
425 Ok(knowledge_text_terms(text, true)?
426 .iter()
427 .all(|term| fields.iter().any(|field| field.contains(term))))
428}
429
430fn validate_filters(filters: &Value) -> Result<()> {
431 validate_knowledge_schema(filters, "searchFilters")
432 .map_err(|e| fail("invalid_request", e.to_string()))?;
433 for key in ["actor", "target", "profile"] {
434 if let Some(value) = filters.get(key) {
435 validate_knowledge_id(
436 string(value)
437 .strip_prefix("did:agent:")
438 .unwrap_or(string(value)),
439 )
440 .map_err(|e| fail("invalid_request", e.to_string()))?;
441 }
442 }
443 if let (Some(from), Some(before)) = (
444 filters["created_from"].knowledge_u64(),
445 filters["created_before"].knowledge_u64(),
446 ) {
447 if from >= before {
448 return Err(fail("invalid_request", "empty time range"));
449 }
450 }
451 Ok(())
452}
453
454pub fn validate_knowledge_query(request: &Value) -> Result<()> {
455 validate_knowledge_schema(request, "queryRequest")
456 .map_err(|e| fail("invalid_request", e.to_string()))?;
457 let mut filters = request.clone();
458 for key in ["cursor", "limit", "after_seq"] {
459 filters.as_object_mut().unwrap().remove(key);
460 }
461 validate_filters(&filters)
462}
463
464pub fn parse_knowledge_query(parameters: &[(String, String)]) -> Result<Value> {
466 let mut request = serde_json::Map::new();
467 for (key, text) in parameters {
468 if request.contains_key(key) {
469 return Err(fail("invalid_request", "repeated parameter"));
470 }
471 let value = if matches!(
472 key.as_str(),
473 "created_from" | "created_before" | "limit" | "after_seq"
474 ) {
475 if text.is_empty() || !text.bytes().all(|b| b.is_ascii_digit()) {
476 return Err(fail(
477 "invalid_request",
478 "integer parameters require decimal digits",
479 ));
480 }
481 let digits = text.trim_start_matches('0');
482 if digits.len() > 16 {
483 return Err(fail("invalid_request", "integer exceeds safe range"));
484 }
485 json!(if digits.is_empty() {
486 0
487 } else {
488 digits
489 .parse::<u64>()
490 .map_err(|_| fail("invalid_request", "invalid integer"))?
491 })
492 } else {
493 json!(text)
494 };
495 request.insert(key.clone(), value);
496 }
497 let request = Value::Object(request);
498 validate_knowledge_query(&request)?;
499 Ok(request)
500}
501
502pub fn knowledge_query_matches(item: &Value, filters: &Value) -> Result<bool> {
504 let e = &item["event"];
505 let p = &e["payload"];
506 let publish = e["type"] == "knowledge.publish";
507 for key in ["actor", "type"] {
508 if filters.get(key).is_some_and(|v| *v != e[key]) {
509 return Ok(false);
510 }
511 }
512 if let Some(v) = filters.get("language") {
513 if !publish || !string(v).eq_ignore_ascii_case(string(&p["language"])) {
514 return Ok(false);
515 }
516 }
517 if let Some(v) = filters.get("verdict") {
518 if e["type"] != "knowledge.assess" || *v != p["verdict"] {
519 return Ok(false);
520 }
521 }
522 if filters["created_from"]
523 .knowledge_i64()
524 .is_some_and(|v| e["created_at"].knowledge_i64().unwrap_or(0) < v)
525 || filters["created_before"]
526 .knowledge_i64()
527 .is_some_and(|v| e["created_at"].knowledge_i64().unwrap_or(0) >= v)
528 {
529 return Ok(false);
530 }
531 if let Some(v) = filters.get("kind") {
532 if !publish || *v != p["kind"] {
533 return Ok(false);
534 }
535 }
536 if let Some(v) = filters.get("tag") {
537 if !publish || !arr(&p["tags"]).contains(v) {
538 return Ok(false);
539 }
540 }
541 if let Some(v) = filters.get("profile") {
542 if !arr(&p["profiles"])
543 .iter()
544 .any(|b| b["profile"]["digest"] == *v)
545 {
546 return Ok(false);
547 }
548 }
549 if let Some(v) = filters.get("relation") {
550 if !publish
551 || !arr(&p["relations"]).iter().any(|r| {
552 r["relation"] == *v && filters.get("target").is_none_or(|t| *t == r["target"])
553 })
554 {
555 return Ok(false);
556 }
557 } else if let Some(v) = filters.get("target") {
558 if !knowledge_dependencies(item)
559 .iter()
560 .any(|id| id == string(v))
561 {
562 return Ok(false);
563 }
564 }
565 Ok(true)
566}
567
568pub fn validate_knowledge_search_request(request: &Value, modes: &[String]) -> Result<()> {
570 identity::parse_strict_json(&serde_json::to_string(request)?)
571 .map_err(|e| fail("invalid_request", e.to_string()))?;
572 if let Some(mode) = request["mode"].as_str() {
573 if !SEARCH_MODES.contains(&mode) || !modes.iter().any(|m| m == mode) {
574 return Err(fail(
575 "unsupported_search_mode",
576 "explicit mode is not supported",
577 ));
578 }
579 }
580 validate_knowledge_schema(request, "searchRequest")
581 .map_err(|e| fail("invalid_request", e.to_string()))?;
582 knowledge_text_terms(string(&request["text"]), request["mode"] == "lexical")?;
583 validate_filters(request.get("filters").unwrap_or(&json!({})))
584}
585
586pub fn validate_knowledge_batch_request(request: &Value) -> Result<Vec<String>> {
587 validate_knowledge_schema(request, "batchRequest")
588 .map_err(|e| fail("invalid_request", e.to_string()))?;
589 arr(&request["hashes"])
590 .iter()
591 .map(|v| {
592 validate_knowledge_id(string(v)).map_err(|e| fail("invalid_request", e.to_string()))?;
593 Ok(string(v).to_owned())
594 })
595 .collect()
596}