Skip to main content

agent_protocols/knowledge/
mod.rs

1//! Agent Knowledge 1.0 signed contributions and discovery.
2//!
3//! JSON boundary APIs preserve complete signed values. Typed payloads preserve
4//! absent versus explicitly empty optional fields; application data stays opaque.
5//! Knowledge events are immutable, portable objects: acceptance never consults
6//! a live-write nonce cache. Core validation never fetches URLs, executes
7//! procedures, or certifies truth.
8
9mod 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];
35/// Default allowance for `created_at` ahead of the receiver clock.
36pub const DEFAULT_FUTURE_SKEW_MS: i64 = 300_000;
37pub const DEFAULT_MAX_ENVELOPE_BYTES: usize = 262_144;
38/// Relationships that may point at an assessment as well as a publication.
39const 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
51/// JSON Schema integers include 1.0. These accessors accept all safe integral
52/// IEEE-754 representations without truncation or unchecked numeric casts.
53pub(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
103/// Validate a bundled structural definition. This does not establish semantics.
104pub 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
127/// Strict JSON parsing suitable for signed objects (duplicate members rejected).
128pub 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
138/// Require a canonical unpadded base64url encoding of exactly 32 bytes.
139pub 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
176/// Validate structure, strict I-JSON, Identity hash/signature, URLs, canonical
177/// IDs and profile binding uniqueness. Dependencies are checked separately.
178pub 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
210/// Sorted, distinct direct dependencies. Call after common validation.
211pub 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
225/// Check target rules against retained envelopes that already passed validation.
226pub 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
282/// Deterministic known-set lifecycle view. Rejects unvalidated or open sets.
283pub 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
368/// Compare a digest with complete representation bytes (after transfer and
369/// content decoding, before text conversion). Pass `None` bytes when the
370/// complete representation could not be obtained. Nothing is fetched.
371pub 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
386/// Portable ASCII-only case folding; Unicode normalization is not performed.
387pub 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
464/// Parse decoded HTTP parameter pairs; duplicate names remain observable.
465pub 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
502/// Exact filter predicate; `after_seq`, `limit` and `cursor` are not payload filters.
503pub 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
568/// Validate an unsigned search request; an unadvertised mode is never substituted.
569pub 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}