1use super::*;
2use std::sync::Arc;
3
4pub 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
175fn 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#[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 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
273pub type KnowledgeAdmission = Arc<dyn Fn(&Value) -> Result<()> + Send + Sync>;
275
276#[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 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 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 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 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 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 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 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 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}