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 https_url(string(&p["license"]))?;
187 for evidence in arr(&p["evidence"]) {
188 https_url(string(&evidence["url"]))?;
189 if let Some(digest) = evidence.get("digest") {
190 validate_knowledge_id(string(digest))?;
191 }
192 }
193 let mut profiles = BTreeSet::new();
194 for binding in arr(&p["profiles"]) {
195 https_url(string(&binding["profile"]["url"]))?;
196 let digest = string(&binding["profile"]["digest"]);
197 validate_knowledge_id(digest)?;
198 if !profiles.insert(digest) {
199 return Err(fail("invalid_event", "duplicate profile digest"));
200 }
201 }
202 for target in knowledge_dependencies(value) {
203 validate_knowledge_id(&target)?;
204 }
205 Ok(envelope)
206}
207
208pub fn knowledge_dependencies(value: &Value) -> Vec<String> {
210 let p = &value["event"]["payload"];
211 if value["event"]["type"] == "knowledge.publish" {
212 arr(&p["relations"])
213 .iter()
214 .map(|r| string(&r["target"]).to_owned())
215 .collect::<BTreeSet<_>>()
216 .into_iter()
217 .collect()
218 } else {
219 vec![string(&p["target"]).to_owned()]
220 }
221}
222
223pub fn validate_knowledge_dependencies(
225 value: &Value,
226 retained: &BTreeMap<String, Value>,
227) -> Result<()> {
228 check_dependencies(value, |id| retained.get(id))
229}
230
231pub(crate) fn check_dependencies<'a>(
232 value: &Value,
233 lookup: impl Fn(&str) -> Option<&'a Value>,
234) -> Result<()> {
235 let missing: Vec<_> = knowledge_dependencies(value)
236 .into_iter()
237 .filter(|id| lookup(id).is_none())
238 .collect();
239 if !missing.is_empty() {
240 return Err(SdkError::protocol_with_data(
241 "missing_dependency",
242 "unresolved dependencies",
243 json!({"missing": missing}),
244 ));
245 }
246 let event = &value["event"];
247 let links = if event["type"] == "knowledge.publish" {
248 arr(&event["payload"]["relations"]).to_vec()
249 } else {
250 vec![json!({"relation": event["type"], "target": event["payload"]["target"]})]
251 };
252 for link in links {
253 let target = &lookup(string(&link["target"])).expect("resolved above")["event"];
254 let relation = string(&link["relation"]);
255 let allowed = target["type"] == "knowledge.publish"
256 || (target["type"] == "knowledge.assess"
257 && (relation == "knowledge.retract" || ASSESSMENT_RELATIONS.contains(&relation)));
258 if !allowed
259 || (relation == "addresses" && target["payload"]["kind"] != "question")
260 || (relation == "tests" && target["payload"]["kind"] != "hypothesis")
261 {
262 return Err(fail(
263 "invalid_target",
264 "wrong target type or contribution kind",
265 ));
266 }
267 if matches!(relation, "supersedes" | "knowledge.retract")
268 && (target["actor"] != event["actor"]
269 || target["nonce"].knowledge_u64() >= event["nonce"].knowledge_u64())
270 {
271 return Err(fail(
272 "invalid_target",
273 "author must match and target nonce must be smaller",
274 ));
275 }
276 }
277 Ok(())
278}
279
280pub fn materialize_knowledge(retained: &BTreeMap<String, Value>) -> Result<Value> {
282 for (id, item) in retained {
283 validate_knowledge_envelope(item)?;
284 if string(&item["hash"]) != id {
285 return Err(fail("invalid_event", "known-set key differs from hash"));
286 }
287 }
288 for item in retained.values() {
289 validate_knowledge_dependencies(item, retained)?;
290 }
291 let withdrawn: BTreeSet<&str> = retained
292 .values()
293 .filter(|v| v["event"]["type"] == "knowledge.retract")
294 .map(|v| string(&v["event"]["payload"]["target"]))
295 .collect();
296 let mut successors: BTreeMap<&str, BTreeSet<&str>> = BTreeMap::new();
297 let mut assessments: BTreeMap<&str, BTreeSet<&str>> = BTreeMap::new();
298 for (id, item) in retained {
299 let event = &item["event"];
300 if event["type"] == "knowledge.publish" {
301 for link in arr(&event["payload"]["relations"]) {
302 if link["relation"] == "supersedes" {
303 successors
304 .entry(string(&link["target"]))
305 .or_default()
306 .insert(id);
307 }
308 }
309 } else if event["type"] == "knowledge.assess" {
310 assessments
311 .entry(string(&event["payload"]["target"]))
312 .or_default()
313 .insert(id);
314 }
315 }
316 let mut result = serde_json::Map::new();
317 for (id, item) in retained {
318 if item["event"]["type"] == "knowledge.retract" {
319 continue;
320 }
321 let mut view =
322 json!({"status": if withdrawn.contains(id.as_str()) { "retracted" } else { "active" }});
323 if item["event"]["type"] == "knowledge.publish" {
324 let reports = assessments.get(id.as_str()).cloned().unwrap_or_default();
325 view["active_assessments"] = json!(reports
326 .iter()
327 .filter(|id| !withdrawn.contains(**id))
328 .collect::<Vec<_>>());
329 view["assessments"] = json!(reports);
330 view["successors"] = json!(successors.get(id.as_str()).cloned().unwrap_or_default());
331 }
332 result.insert(id.clone(), view);
333 }
334 Ok(Value::Object(result))
335}
336
337pub fn knowledge_publish_event(
338 actor: AgentId,
339 created_at: i64,
340 nonce: u64,
341 payload: KnowledgePublishPayload,
342) -> Event<KnowledgePublishPayload> {
343 Event::new(PROTOCOL, EVENT_TYPES[0], actor, created_at, nonce, payload)
344}
345pub fn knowledge_assess_event(
346 actor: AgentId,
347 created_at: i64,
348 nonce: u64,
349 payload: KnowledgeAssessPayload,
350) -> Event<KnowledgeAssessPayload> {
351 Event::new(PROTOCOL, EVENT_TYPES[1], actor, created_at, nonce, payload)
352}
353pub fn knowledge_retract_event(
354 actor: AgentId,
355 created_at: i64,
356 nonce: u64,
357 payload: KnowledgeRetractPayload,
358) -> Event<KnowledgeRetractPayload> {
359 Event::new(PROTOCOL, EVENT_TYPES[2], actor, created_at, nonce, payload)
360}
361
362pub fn validate_typed_knowledge_envelope<P: Serialize>(envelope: &Envelope<P>) -> Result<()> {
363 validate_knowledge_envelope(&serde_json::to_value(envelope)?).map(|_| ())
364}
365
366pub fn verify_knowledge_evidence(
370 digest: Option<&str>,
371 bytes: Option<&[u8]>,
372) -> Result<EvidenceStatus> {
373 let Some(digest) = digest else {
374 return Ok(EvidenceStatus::Unchecked);
375 };
376 validate_knowledge_id(digest)?;
377 Ok(match bytes {
378 None => EvidenceStatus::Unavailable,
379 Some(bytes) if sha3_id(bytes) == digest => EvidenceStatus::Matched,
380 Some(_) => EvidenceStatus::Mismatched,
381 })
382}
383
384pub fn knowledge_text_terms(text: &str, lexical: bool) -> Result<Vec<String>> {
386 if !(1..=1024).contains(&text.chars().count()) {
387 return Err(fail(
388 "invalid_request",
389 "text needs 1..1024 Unicode scalars",
390 ));
391 }
392 let terms: Vec<_> = text
393 .split(|c: char| matches!(c, '\u{9}'..='\u{d}' | ' '))
394 .filter(|p| !p.is_empty())
395 .map(str::to_ascii_lowercase)
396 .collect();
397 if terms.is_empty() || (lexical && terms.len() > 16) {
398 return Err(fail("invalid_request", "empty text or too many terms"));
399 }
400 Ok(terms)
401}
402
403pub fn knowledge_text_matches(item: &Value, text: &str) -> Result<bool> {
404 let p = &item["event"]["payload"];
405 let names: &[&str] = match string(&item["event"]["type"]) {
406 "knowledge.publish" => &["title", "statement", "basis"],
407 "knowledge.assess" => &["summary", "basis"],
408 _ => &["reason"],
409 };
410 let mut fields: Vec<String> = names
411 .iter()
412 .map(|name| string(&p[*name]).to_ascii_lowercase())
413 .collect();
414 if let Some(context) = p["context"].as_object() {
415 for value in context.values() {
416 if let Some(s) = value.as_str() {
417 fields.push(s.to_ascii_lowercase());
418 } else {
419 fields.extend(arr(value).iter().map(|v| string(v).to_ascii_lowercase()));
420 }
421 }
422 }
423 Ok(knowledge_text_terms(text, true)?
424 .iter()
425 .all(|term| fields.iter().any(|field| field.contains(term))))
426}
427
428fn validate_filters(filters: &Value) -> Result<()> {
429 validate_knowledge_schema(filters, "searchFilters")
430 .map_err(|e| fail("invalid_request", e.to_string()))?;
431 for key in ["actor", "target", "profile"] {
432 if let Some(value) = filters.get(key) {
433 validate_knowledge_id(
434 string(value)
435 .strip_prefix("did:agent:")
436 .unwrap_or(string(value)),
437 )
438 .map_err(|e| fail("invalid_request", e.to_string()))?;
439 }
440 }
441 if let (Some(from), Some(before)) = (
442 filters["created_from"].knowledge_u64(),
443 filters["created_before"].knowledge_u64(),
444 ) {
445 if from >= before {
446 return Err(fail("invalid_request", "empty time range"));
447 }
448 }
449 Ok(())
450}
451
452pub fn validate_knowledge_query(request: &Value) -> Result<()> {
453 validate_knowledge_schema(request, "queryRequest")
454 .map_err(|e| fail("invalid_request", e.to_string()))?;
455 let mut filters = request.clone();
456 for key in ["q", "cursor", "limit", "after_seq"] {
457 filters.as_object_mut().unwrap().remove(key);
458 }
459 validate_filters(&filters)?;
460 if let Some(q) = request.get("q") {
461 knowledge_text_terms(string(q), true)?;
462 }
463 Ok(())
464}
465
466pub fn parse_knowledge_query(parameters: &[(String, String)]) -> Result<Value> {
468 let mut request = serde_json::Map::new();
469 for (key, text) in parameters {
470 if request.contains_key(key) {
471 return Err(fail("invalid_request", "repeated parameter"));
472 }
473 let value = if matches!(
474 key.as_str(),
475 "created_from" | "created_before" | "limit" | "after_seq"
476 ) {
477 if text.is_empty() || !text.bytes().all(|b| b.is_ascii_digit()) {
478 return Err(fail(
479 "invalid_request",
480 "integer parameters require decimal digits",
481 ));
482 }
483 let digits = text.trim_start_matches('0');
484 if digits.len() > 16 {
485 return Err(fail("invalid_request", "integer exceeds safe range"));
486 }
487 json!(if digits.is_empty() {
488 0
489 } else {
490 digits
491 .parse::<u64>()
492 .map_err(|_| fail("invalid_request", "invalid integer"))?
493 })
494 } else {
495 json!(text)
496 };
497 request.insert(key.clone(), value);
498 }
499 let request = Value::Object(request);
500 validate_knowledge_query(&request)?;
501 Ok(request)
502}
503
504pub fn knowledge_query_matches(item: &Value, filters: &Value) -> Result<bool> {
506 let e = &item["event"];
507 let p = &e["payload"];
508 let publish = e["type"] == "knowledge.publish";
509 for key in ["actor", "type"] {
510 if filters.get(key).is_some_and(|v| *v != e[key]) {
511 return Ok(false);
512 }
513 }
514 if let Some(q) = filters.get("q") {
515 if !knowledge_text_matches(item, string(q))? {
516 return Ok(false);
517 }
518 }
519 if let Some(v) = filters.get("language") {
520 if !publish || !string(v).eq_ignore_ascii_case(string(&p["language"])) {
521 return Ok(false);
522 }
523 }
524 if let Some(v) = filters.get("verdict") {
525 if e["type"] != "knowledge.assess" || *v != p["verdict"] {
526 return Ok(false);
527 }
528 }
529 if filters["created_from"]
530 .knowledge_i64()
531 .is_some_and(|v| e["created_at"].knowledge_i64().unwrap_or(0) < v)
532 || filters["created_before"]
533 .knowledge_i64()
534 .is_some_and(|v| e["created_at"].knowledge_i64().unwrap_or(0) >= v)
535 {
536 return Ok(false);
537 }
538 if let Some(v) = filters.get("kind") {
539 if !publish || *v != p["kind"] {
540 return Ok(false);
541 }
542 }
543 if let Some(v) = filters.get("tag") {
544 if !publish || !arr(&p["tags"]).contains(v) {
545 return Ok(false);
546 }
547 }
548 if let Some(v) = filters.get("profile") {
549 if !arr(&p["profiles"])
550 .iter()
551 .any(|b| b["profile"]["digest"] == *v)
552 {
553 return Ok(false);
554 }
555 }
556 if let Some(v) = filters.get("relation") {
557 if !publish
558 || !arr(&p["relations"]).iter().any(|r| {
559 r["relation"] == *v && filters.get("target").is_none_or(|t| *t == r["target"])
560 })
561 {
562 return Ok(false);
563 }
564 } else if let Some(v) = filters.get("target") {
565 if !knowledge_dependencies(item)
566 .iter()
567 .any(|id| id == string(v))
568 {
569 return Ok(false);
570 }
571 }
572 Ok(true)
573}
574
575pub fn validate_knowledge_search_request(request: &Value, modes: &[String]) -> Result<()> {
577 identity::parse_strict_json(&serde_json::to_string(request)?)
578 .map_err(|e| fail("invalid_request", e.to_string()))?;
579 if let Some(mode) = request["mode"].as_str() {
580 if !SEARCH_MODES.contains(&mode) || !modes.iter().any(|m| m == mode) {
581 return Err(fail(
582 "unsupported_search_mode",
583 "explicit mode is not supported",
584 ));
585 }
586 }
587 validate_knowledge_schema(request, "searchRequest")
588 .map_err(|e| fail("invalid_request", e.to_string()))?;
589 knowledge_text_terms(string(&request["text"]), request["mode"] == "lexical")?;
590 validate_filters(request.get("filters").unwrap_or(&json!({})))
591}
592
593pub fn validate_knowledge_batch_request(request: &Value) -> Result<Vec<String>> {
594 validate_knowledge_schema(request, "batchRequest")
595 .map_err(|e| fail("invalid_request", e.to_string()))?;
596 arr(&request["hashes"])
597 .iter()
598 .map(|v| {
599 validate_knowledge_id(string(v)).map_err(|e| fail("invalid_request", e.to_string()))?;
600 Ok(string(v).to_owned())
601 })
602 .collect()
603}