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    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
208/// Sorted, distinct direct dependencies. Call after common validation.
209pub 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
223/// Check target rules against retained envelopes that already passed validation.
224pub 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
280/// Deterministic known-set lifecycle view. Rejects unvalidated or open sets.
281pub 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
366/// Compare a digest with complete representation bytes (after transfer and
367/// content decoding, before text conversion). Pass `None` bytes when the
368/// complete representation could not be obtained. Nothing is fetched.
369pub 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
384/// Portable ASCII-only case folding; Unicode normalization is not performed.
385pub 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
466/// Parse decoded HTTP parameter pairs; duplicate names remain observable.
467pub 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
504/// Exact filter and text predicate; `after_seq`, `limit` and `cursor` are not payload filters.
505pub 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
575/// Validate an unsigned search request; an unadvertised mode is never substituted.
576pub 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}