Skip to main content

agent_protocols/knowledge/
store.rs

1use super::*;
2use std::sync::Arc;
3
4/// Validate a service receipt and optionally bind it to the requested ID.
5pub fn validate_knowledge_record(record: &Value, expected_hash: Option<&str>) -> Result<()> {
6    validate_knowledge_schema(record, "acceptanceRecord")
7        .map_err(|e| fail("invalid_response", e.to_string()))?;
8    validate_knowledge_envelope(&record["envelope"])
9        .map_err(|e| fail("invalid_response", e.to_string()))?;
10    if expected_hash.is_some_and(|id| id != string(&record["envelope"]["hash"])) {
11        return Err(fail("invalid_response", "returned wrong event ID"));
12    }
13    Ok(())
14}
15
16fn response_records<'a>(
17    response: &'a Value,
18    definition: &str,
19    origin: &str,
20) -> Result<Vec<&'a Value>> {
21    validate_knowledge_schema(response, definition)
22        .map_err(|e| fail("invalid_response", e.to_string()))?;
23    identity::validate_origin(string(&response["service"]))
24        .map_err(|e| fail("invalid_response", e.to_string()))?;
25    if response["service"] != origin {
26        return Err(fail(
27            "invalid_response",
28            "response service differs from receiving origin",
29        ));
30    }
31    let records: Vec<&Value> = arr(&response["result"])
32        .iter()
33        .map(|item| {
34            if definition == "searchResponse" {
35                &item["record"]
36            } else {
37                item
38            }
39        })
40        .collect();
41    let mut ids = BTreeSet::new();
42    for record in &records {
43        if !ids.insert(string(&record["envelope"]["hash"]))
44            || record["seq"].knowledge_u64() > response["checkpoint"].knowledge_u64()
45        {
46            return Err(fail(
47                "invalid_response",
48                "duplicate event or record beyond checkpoint",
49            ));
50        }
51        validate_knowledge_record(record, None)?;
52    }
53    Ok(records)
54}
55
56pub fn validate_knowledge_query_response(
57    response: &Value,
58    request: &Value,
59    origin: &str,
60) -> Result<()> {
61    validate_knowledge_query(request)?;
62    let records = response_records(response, "queryResponse", origin)?;
63    if records.len() > request["limit"].knowledge_u64().unwrap_or(100) as usize {
64        return Err(fail("invalid_response", "query page exceeds limit"));
65    }
66    let mut last = request["after_seq"].knowledge_u64().unwrap_or(0);
67    for record in records {
68        let seq = record["seq"].knowledge_u64().unwrap_or(0);
69        if seq <= last || !knowledge_query_matches(&record["envelope"], request)? {
70            return Err(fail("invalid_response", "query order or filters violated"));
71        }
72        last = seq;
73    }
74    Ok(())
75}
76
77pub fn validate_knowledge_batch_response(
78    response: &Value,
79    requested: &[String],
80    origin: &str,
81) -> Result<()> {
82    validate_knowledge_batch_request(&json!({"hashes": requested}))?;
83    let result: Vec<_> = response_records(response, "batchResponse", origin)?
84        .iter()
85        .map(|r| string(&r["envelope"]["hash"]).to_owned())
86        .collect();
87    let missing: Vec<_> = arr(&response["missing"])
88        .iter()
89        .map(|r| string(r).to_owned())
90        .collect();
91    let rset: BTreeSet<_> = result.iter().cloned().collect();
92    let mset: BTreeSet<_> = missing.iter().cloned().collect();
93    let ordered = |set: &BTreeSet<String>| -> Vec<String> {
94        requested
95            .iter()
96            .filter(|id| set.contains(*id))
97            .cloned()
98            .collect()
99    };
100    if !rset.is_disjoint(&mset)
101        || rset.union(&mset).cloned().collect::<BTreeSet<_>>()
102            != requested.iter().cloned().collect()
103        || result != ordered(&rset)
104        || missing != ordered(&mset)
105    {
106        return Err(fail(
107            "invalid_response",
108            "batch is not an ordered complete partition",
109        ));
110    }
111    Ok(())
112}
113
114pub fn validate_knowledge_search_response(
115    response: &Value,
116    request: &Value,
117    origin: &str,
118) -> Result<()> {
119    validate_knowledge_search_request(request, &SEARCH_MODES.map(String::from))?;
120    let records = response_records(response, "searchResponse", origin)?;
121    if response["ranking"]["mode"] != request["mode"]
122        || records.len() > request["limit"].knowledge_u64().unwrap_or(20) as usize
123    {
124        return Err(fail("invalid_response", "search mode or limit changed"));
125    }
126    let empty = json!({});
127    for record in records {
128        let item = &record["envelope"];
129        if !knowledge_query_matches(item, request.get("filters").unwrap_or(&empty))?
130            || (request["mode"] == "lexical"
131                && !knowledge_text_matches(item, string(&request["text"]))?)
132        {
133            return Err(fail(
134                "invalid_response",
135                "search violated exact filters or lexical text",
136            ));
137        }
138    }
139    Ok(())
140}
141
142pub fn validate_knowledge_discovery(document: &Value, origin: &str) -> Result<()> {
143    (|| -> Result<()> {
144        validate_knowledge_schema(document, "discoveryDocument")?;
145        identity::validate_origin(origin)?;
146        identity::validate_origin(string(&document["service"]))?;
147        if document["service"] != origin {
148            return Err(fail("invalid_discovery", "wrong serving origin"));
149        }
150        if let Some(endpoints) = document["endpoints"].as_object() {
151            for endpoint in endpoints.values() {
152                let url = https_url(string(endpoint))?;
153                if url.query().is_some()
154                    || url.fragment().is_some()
155                    || url.origin().ascii_serialization() != origin
156                {
157                    return Err(fail(
158                        "invalid_discovery",
159                        "endpoint must use receiving origin without query or fragment",
160                    ));
161                }
162            }
163        }
164        for peer in arr(&document["peers"]) {
165            identity::validate_origin(string(peer))?;
166            if *peer == origin {
167                return Err(fail("invalid_discovery", "self peer"));
168            }
169        }
170        Ok(())
171    })()
172    .map_err(|e| fail("invalid_discovery", e.to_string()))
173}
174
175/// Effective query for cursor binding: no cursor, defaults applied.
176fn query_binding(request: &Value) -> Value {
177    let mut value = normalized_json(request);
178    let obj = value.as_object_mut().expect("validated query is an object");
179    obj.remove("cursor");
180    obj.entry("limit").or_insert(json!(100));
181    obj.entry("after_seq").or_insert(json!(0));
182    value
183}
184
185/// Verifies that query pages form one complete, consistent enumeration.
186#[derive(Clone, Debug)]
187pub struct KnowledgePageTracker {
188    origin: String,
189    binding: Option<Value>,
190    scope: Option<Value>,
191    seen: BTreeSet<String>,
192    last: u64,
193    next_cursor: Option<String>,
194}
195impl KnowledgePageTracker {
196    pub fn new(origin: &str) -> Result<Self> {
197        identity::validate_origin(origin)?;
198        Ok(Self {
199            origin: origin.into(),
200            binding: None,
201            scope: None,
202            seen: BTreeSet::new(),
203            last: 0,
204            next_cursor: None,
205        })
206    }
207    pub fn accept(&mut self, request: &Value, response: &Value) -> Result<()> {
208        validate_knowledge_query_response(response, request, &self.origin)?;
209        let binding = query_binding(request);
210        let scope = normalized_json(&json!({
211            "service": response["service"],
212            "checkpoint": response["checkpoint"],
213            "as_of": response["as_of"],
214        }));
215        let mut last = self.last;
216        if self.binding.is_none() {
217            if request.get("cursor").is_some() {
218                return Err(fail(
219                    "invalid_response",
220                    "a traversal must start without a cursor",
221                ));
222            }
223            last = binding["after_seq"].knowledge_u64().unwrap_or(0);
224        } else if self.next_cursor.is_none()
225            || request["cursor"].as_str() != self.next_cursor.as_deref()
226            || self.binding.as_ref() != Some(&binding)
227            || self.scope.as_ref() != Some(&scope)
228        {
229            return Err(fail(
230                "invalid_response",
231                "pagination request or checkpoint scope changed",
232            ));
233        }
234        let records = arr(&response["result"]);
235        if records
236            .first()
237            .is_some_and(|r| r["seq"].knowledge_u64().unwrap_or(0) <= last)
238            || records
239                .iter()
240                .any(|r| self.seen.contains(string(&r["envelope"]["hash"])))
241        {
242            return Err(fail(
243                "invalid_response",
244                "pagination repeated an event or moved backwards",
245            ));
246        }
247        self.seen.extend(
248            records
249                .iter()
250                .map(|r| string(&r["envelope"]["hash"]).to_owned()),
251        );
252        if let Some(record) = records.last() {
253            last = record["seq"].knowledge_u64().unwrap_or(last);
254        }
255        self.last = last;
256        self.binding = Some(binding);
257        self.scope = Some(scope);
258        self.next_cursor = response["next_cursor"].as_str().map(str::to_owned);
259        Ok(())
260    }
261    pub fn is_complete(&self) -> bool {
262        self.binding.is_some() && self.next_cursor.is_none()
263    }
264    /// The next poll's `after_seq`, available only after every page was consumed.
265    pub fn checkpoint(&self) -> Option<u64> {
266        self.scope
267            .as_ref()
268            .filter(|_| self.is_complete())
269            .and_then(|scope| scope["checkpoint"].knowledge_u64())
270    }
271}
272
273/// Admission hook run for each new acceptance after all protocol checks.
274pub type KnowledgeAdmission = Arc<dyn Fn(&Value) -> Result<()> + Send + Sync>;
275
276/// Single-process, in-memory reference repository with checkpoint-bound,
277/// stateless query cursors. Mutations require `&mut self`; integrate behind
278/// your application's lock/transaction. It performs no fetching, persistence,
279/// HTTP serving, authorization or ranking. Returned values are owned clones.
280#[derive(Clone)]
281pub struct KnowledgeStore {
282    origin: String,
283    records: BTreeMap<String, Value>,
284    by_seq: BTreeMap<u64, String>,
285    hidden: BTreeSet<String>,
286    seq: u64,
287    admit: Option<KnowledgeAdmission>,
288    /// Allowance for `created_at` ahead of the clock; there is no lower bound.
289    pub future_skew_ms: i64,
290    pub max_envelope_bytes: usize,
291}
292impl std::fmt::Debug for KnowledgeStore {
293    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
294        f.debug_struct("KnowledgeStore")
295            .field("origin", &self.origin)
296            .field("records", &self.records)
297            .field("hidden", &self.hidden)
298            .field("seq", &self.seq)
299            .field("future_skew_ms", &self.future_skew_ms)
300            .field("max_envelope_bytes", &self.max_envelope_bytes)
301            .finish_non_exhaustive()
302    }
303}
304impl KnowledgeStore {
305    pub fn new(origin: impl Into<String>) -> Result<Self> {
306        let origin = origin.into();
307        identity::validate_origin(&origin)?;
308        Ok(Self {
309            origin,
310            records: BTreeMap::new(),
311            by_seq: BTreeMap::new(),
312            hidden: BTreeSet::new(),
313            seq: 0,
314            admit: None,
315            future_skew_ms: DEFAULT_FUTURE_SKEW_MS,
316            max_envelope_bytes: DEFAULT_MAX_ENVELOPE_BYTES,
317        })
318    }
319    /// Refuse otherwise valid objects under local policy; return an error to refuse.
320    pub fn set_admission(&mut self, admit: impl Fn(&Value) -> Result<()> + Send + Sync + 'static) {
321        self.admit = Some(Arc::new(admit));
322    }
323    pub fn checkpoint(&self) -> u64 {
324        self.seq
325    }
326    /// Publicly visible envelopes keyed by event ID.
327    pub fn known_envelopes(&self) -> BTreeMap<String, Value> {
328        self.records
329            .iter()
330            .filter(|(id, _)| !self.hidden.contains(*id))
331            .map(|(id, record)| (id.clone(), record["envelope"].clone()))
332            .collect()
333    }
334    /// Accept a signed event, or return the original record of an exact resubmission.
335    pub fn submit(&mut self, item: &Value, now: i64) -> Result<Value> {
336        let envelope = validate_knowledge_envelope(item)?;
337        if let Some(record) = self.records.get(&envelope.hash) {
338            return Ok(record.clone());
339        }
340        if now < 0 || now as u64 > identity::MAX_SAFE_NONCE || self.future_skew_ms < 0 {
341            return Err(fail("invalid_request", "invalid clock or skew allowance"));
342        }
343        if envelope.event.created_at > now.saturating_add(self.future_skew_ms) {
344            return Err(SdkError::TimestampOutOfWindow);
345        }
346        check_dependencies(item, |id| self.records.get(id).map(|r| &r["envelope"]))?;
347        if serde_json::to_vec(item)?.len() > self.max_envelope_bytes {
348            return Err(fail(
349                "payload_too_large",
350                "envelope exceeds configured byte limit",
351            ));
352        }
353        if self.seq == identity::MAX_SAFE_NONCE {
354            return Err(fail("permission_denied", "sequence space exhausted"));
355        }
356        if let Some(admit) = &self.admit {
357            admit(item)?;
358        }
359        self.seq += 1;
360        let record = json!({"envelope": item, "accepted_at": now, "seq": self.seq});
361        self.records.insert(envelope.hash.clone(), record.clone());
362        self.by_seq.insert(self.seq, envelope.hash);
363        Ok(record)
364    }
365    pub fn event(&self, hash: &str) -> Result<Value> {
366        validate_knowledge_id(hash).map_err(|e| fail("invalid_request", e.to_string()))?;
367        if self.hidden.contains(hash) {
368            return Err(fail("not_found", "event unavailable"));
369        }
370        self.records
371            .get(hash)
372            .cloned()
373            .ok_or_else(|| fail("not_found", "event unavailable"))
374    }
375    /// Withhold from public reads; the record still answers exact retries and resolves dependencies.
376    pub fn hide(&mut self, hash: &str) {
377        if self.records.contains_key(hash) {
378            self.hidden.insert(hash.into());
379        }
380    }
381    pub fn unhide(&mut self, hash: &str) {
382        self.hidden.remove(hash);
383    }
384    /// Drop content and record; the sequence high-water mark is preserved.
385    pub fn prune(&mut self, hash: &str) {
386        if let Some(record) = self.records.remove(hash) {
387            self.by_seq
388                .remove(&record["seq"].knowledge_u64().unwrap_or(0));
389        }
390        self.hidden.remove(hash);
391    }
392    fn scope(&self, checkpoint: u64, as_of: i64) -> Result<Value> {
393        if as_of < 0 || as_of as u64 > identity::MAX_SAFE_NONCE {
394            return Err(fail("invalid_request", "invalid service clock"));
395        }
396        Ok(json!({"service": self.origin, "checkpoint": checkpoint, "as_of": as_of}))
397    }
398    fn visible(&self, hash: &str) -> Option<&Value> {
399        self.records
400            .get(hash)
401            .filter(|_| !self.hidden.contains(hash))
402    }
403    pub fn batch(&self, request: &Value, now: i64) -> Result<Value> {
404        let hashes = validate_knowledge_batch_request(request)?;
405        let mut result = Vec::new();
406        let mut missing = Vec::new();
407        for id in hashes {
408            match self.visible(&id) {
409                Some(record) => result.push(record.clone()),
410                None => missing.push(id),
411            }
412        }
413        let mut response = self.scope(self.seq, now)?;
414        response["result"] = json!(result);
415        response["missing"] = json!(missing);
416        Ok(response)
417    }
418    pub fn query(&self, request: &Value, now: i64) -> Result<Value> {
419        validate_knowledge_query(request)?;
420        let binding = query_binding(request);
421        let digest = sha3_id(&serde_jcs::to_vec(&binding)?);
422        let after = binding["after_seq"].knowledge_u64().unwrap_or(0);
423        let limit = binding["limit"].knowledge_u64().unwrap_or(100) as usize;
424        let (checkpoint, as_of, last) = match request["cursor"].as_str() {
425            Some(cursor) => self.decode_cursor(cursor, &digest, after)?,
426            None if after > self.seq => {
427                return Err(fail(
428                    "invalid_request",
429                    "after_seq is greater than the current checkpoint",
430                ))
431            }
432            None => (self.seq, now, after),
433        };
434        let mut result: Vec<Value> = Vec::new();
435        let mut next_cursor = None;
436        // `last == checkpoint` is an empty poll; BTreeMap ranges panic when start > end.
437        let range = if last < checkpoint {
438            self.by_seq.range(last + 1..=checkpoint)
439        } else {
440            self.by_seq.range(0..0)
441        };
442        for hash in range.map(|(_, hash)| hash) {
443            let Some(record) = self.visible(hash) else {
444                continue;
445            };
446            if !knowledge_query_matches(&record["envelope"], request)? {
447                continue;
448            }
449            if result.len() == limit {
450                let returned = result
451                    .last()
452                    .and_then(|r| r["seq"].knowledge_u64())
453                    .unwrap_or(last);
454                next_cursor = Some(format!("{checkpoint}.{as_of}.{returned}.{digest}"));
455                break;
456            }
457            result.push(record.clone());
458        }
459        let mut response = self.scope(checkpoint, as_of)?;
460        response["result"] = json!(result);
461        if let Some(cursor) = next_cursor {
462            response["next_cursor"] = json!(cursor);
463        }
464        Ok(response)
465    }
466    fn decode_cursor(&self, cursor: &str, digest: &str, after: u64) -> Result<(u64, i64, u64)> {
467        let invalid = || fail("invalid_cursor", "malformed, expired or mismatched cursor");
468        let parts: Vec<&str> = cursor.split('.').collect();
469        if parts.len() != 4
470            || parts[3] != digest
471            || parts[..3]
472                .iter()
473                .any(|p| p.is_empty() || p.len() > 16 || !p.bytes().all(|b| b.is_ascii_digit()))
474        {
475            return Err(invalid());
476        }
477        let number = |p: &str| p.parse::<u64>().map_err(|_| invalid());
478        let (checkpoint, as_of, last) = (number(parts[0])?, number(parts[1])?, number(parts[2])?);
479        if !(after <= last && last <= checkpoint && checkpoint <= self.seq)
480            || as_of > identity::MAX_SAFE_NONCE
481        {
482            return Err(invalid());
483        }
484        Ok((checkpoint, as_of as i64, last))
485    }
486    /// Return one page of caller-ranked candidates; no ranking model is implied.
487    /// Only a lexical page containing every match may claim exhaustive coverage.
488    pub fn search(
489        &self,
490        request: &Value,
491        selection: &KnowledgeSearchSelection,
492        modes: &[String],
493        now: i64,
494    ) -> Result<Value> {
495        validate_knowledge_search_request(request, modes)?;
496        let limit = request["limit"].knowledge_u64().unwrap_or(20) as usize;
497        let empty = json!({});
498        let filters = request.get("filters").unwrap_or(&empty);
499        let mut eligible = BTreeSet::new();
500        for (hash, record) in &self.records {
501            if !self.hidden.contains(hash)
502                && knowledge_query_matches(&record["envelope"], filters)?
503                && (request["mode"] != "lexical"
504                    || knowledge_text_matches(&record["envelope"], string(&request["text"]))?)
505            {
506                eligible.insert(hash.as_str());
507            }
508        }
509        let ids = &selection.candidates;
510        let candidates: BTreeSet<&str> = ids.iter().map(String::as_str).collect();
511        if candidates.len() != ids.len() || !candidates.is_subset(&eligible) {
512            return Err(fail(
513                "invalid_response",
514                "candidates repeat or violate exact filters",
515            ));
516        }
517        if selection.coverage["exhaustive"] == true && (candidates != eligible || ids.len() > limit)
518        {
519            return Err(fail("invalid_response", "false exhaustive coverage"));
520        }
521        let hits: Vec<Value> = ids
522            .iter()
523            .take(limit)
524            .map(|id| {
525                let mut hit = json!({"record": self.records[id]});
526                if let Some(explanation) = selection.explanations.get(id) {
527                    hit["explanation"] = json!(explanation);
528                }
529                hit
530            })
531            .collect();
532        let mut response = self.scope(self.seq, now)?;
533        response["result"] = json!(hits);
534        response["ranking"] = selection.ranking.clone();
535        response["coverage"] = selection.coverage.clone();
536        validate_knowledge_schema(&response, "searchResponse")
537            .map_err(|e| fail("invalid_response", e.to_string()))?;
538        if response["ranking"]["mode"] != request["mode"] {
539            return Err(fail(
540                "invalid_response",
541                "ranking mode differs from requested mode",
542            ));
543        }
544        Ok(response)
545    }
546}