1#![deny(missing_docs)]
31
32use std::{
33 collections::HashSet,
34 fmt,
35 fs::{self, File, OpenOptions},
36 io::{Read, Write},
37 path::{Component, Path, PathBuf},
38};
39
40use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD};
41use chrono::{DateTime, Utc};
42use rand::RngCore;
43use rusqlite::{Connection, OptionalExtension, Transaction, TransactionBehavior, params};
44use serde::{Deserialize, Deserializer, Serialize, Serializer, de};
45use sha2::{Digest, Sha256};
46
47const SCHEMA: &str = include_str!("../schema.sql");
48const INLINE_PROVENANCE_LIMIT: usize = 256 * 1024;
49
50#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
51pub struct NodeId([u8; 20]);
56
57impl NodeId {
58 pub fn random() -> Self {
60 let mut bytes = [0_u8; 20];
61 rand::rng().fill_bytes(&mut bytes);
62 Self(bytes)
63 }
64
65 pub fn from_hex(value: &str) -> Result<Self, Error> {
67 if value.len() != 40
68 || !value
69 .bytes()
70 .all(|byte| byte.is_ascii_hexdigit() && !byte.is_ascii_uppercase())
71 {
72 return Err(Error::InvalidInput(
73 "Identifiers must be 40 lowercase hexadecimal characters.".into(),
74 ));
75 }
76 let decoded =
77 hex::decode(value).map_err(|_| Error::InvalidInput("Invalid identifier.".into()))?;
78 let bytes: [u8; 20] = decoded
79 .try_into()
80 .map_err(|_| Error::InvalidInput("Invalid identifier.".into()))?;
81 Ok(Self(bytes))
82 }
83
84 pub fn to_hex(self) -> String {
86 hex::encode(self.0)
87 }
88
89 fn as_bytes(&self) -> &[u8] {
90 &self.0
91 }
92
93 fn from_blob(value: Vec<u8>) -> rusqlite::Result<Self> {
94 let length = value.len();
95 let bytes = value.try_into().map_err(|_| {
96 rusqlite::Error::FromSqlConversionFailure(
97 length,
98 rusqlite::types::Type::Blob,
99 "Kmap identifier is not 20 bytes".into(),
100 )
101 })?;
102 Ok(Self(bytes))
103 }
104}
105
106impl fmt::Display for NodeId {
107 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
108 formatter.write_str(&hex::encode(self.0))
109 }
110}
111
112impl Serialize for NodeId {
113 fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
114 where
115 S: Serializer,
116 {
117 serializer.serialize_str(&self.to_string())
118 }
119}
120
121impl<'de> Deserialize<'de> for NodeId {
122 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
123 where
124 D: Deserializer<'de>,
125 {
126 let value = String::deserialize(deserializer)?;
127 Self::from_hex(&value).map_err(de::Error::custom)
128 }
129}
130
131pub type ProvenanceId = NodeId;
133
134#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
135pub struct IdempotencyId([u8; 16]);
140
141impl IdempotencyId {
142 pub fn random() -> Self {
144 let mut bytes = [0_u8; 16];
145 rand::rng().fill_bytes(&mut bytes);
146 Self(bytes)
147 }
148
149 pub fn from_hex(value: &str) -> Result<Self, Error> {
151 if value.len() != 32
152 || !value
153 .bytes()
154 .all(|byte| byte.is_ascii_hexdigit() && !byte.is_ascii_uppercase())
155 {
156 return Err(Error::InvalidInput(
157 "Idempotency identifiers must be 32 lowercase hexadecimal characters.".into(),
158 ));
159 }
160 let decoded = hex::decode(value)
161 .map_err(|_| Error::InvalidInput("Invalid idempotency identifier.".into()))?;
162 let bytes: [u8; 16] = decoded
163 .try_into()
164 .map_err(|_| Error::InvalidInput("Invalid idempotency identifier.".into()))?;
165 Ok(Self(bytes))
166 }
167
168 pub fn to_hex(self) -> String {
170 hex::encode(self.0)
171 }
172
173 fn as_bytes(&self) -> &[u8] {
174 &self.0
175 }
176}
177
178impl fmt::Display for IdempotencyId {
179 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
180 formatter.write_str(&hex::encode(self.0))
181 }
182}
183
184impl Serialize for IdempotencyId {
185 fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
186 where
187 S: Serializer,
188 {
189 serializer.serialize_str(&self.to_string())
190 }
191}
192
193impl<'de> Deserialize<'de> for IdempotencyId {
194 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
195 where
196 D: Deserializer<'de>,
197 {
198 let value = String::deserialize(deserializer)?;
199 Self::from_hex(&value).map_err(de::Error::custom)
200 }
201}
202
203#[derive(Clone, Debug, Serialize)]
204pub struct Node {
206 pub id: NodeId,
208 pub short_name: String,
210 pub short_description: String,
212 pub long_description: String,
214 pub last_modified_by: String,
216 pub last_modified_at: String,
218 pub owner_node_id: Option<NodeId>,
220 pub fixed_connections: Vec<NodeId>,
222 pub recent_connections: Vec<NodeId>,
224}
225
226#[derive(Clone, Copy, Debug)]
227pub enum Owner {
229 Unowned,
231 SelfNode,
233 Node(NodeId),
235}
236
237#[derive(Clone, Debug)]
238pub struct CreateNode {
240 pub id: NodeId,
242 pub provenance_id: ProvenanceId,
244 pub owner: Owner,
246 pub short_name: String,
248 pub short_description: String,
250 pub long_description: String,
252 pub model_attribution: String,
254 pub fixed_connections: Vec<NodeId>,
256 pub recent_connections: Vec<NodeId>,
258}
259
260#[derive(Clone, Debug)]
261pub struct UpdateNode {
263 pub provenance_id: ProvenanceId,
265 pub owner: Owner,
267 pub short_name: String,
269 pub short_description: String,
271 pub long_description: String,
273 pub model_attribution: String,
275 pub fixed_connections: Vec<NodeId>,
277 pub recent_connections: Vec<NodeId>,
279}
280
281#[derive(Clone, Debug, Deserialize)]
282pub struct NewProvenance {
284 pub data: String,
286 pub source: String,
288 pub source_created_at: String,
290}
291
292#[derive(Clone, Debug)]
293pub struct NewProvenanceArtifact {
295 pub original_filename: String,
297 pub media_type: String,
299 pub role: String,
301 pub data: Vec<u8>,
303}
304
305#[derive(Clone, Debug)]
306pub struct ProvenanceStorage {
308 pub data_filename: String,
310 pub artifacts: Vec<NewProvenanceArtifact>,
312}
313
314#[derive(Clone, Debug, Serialize)]
315pub struct ProvenanceArtifact {
317 pub relative_path: String,
319 pub original_filename: String,
321 pub media_type: String,
323 pub role: String,
325 pub byte_length: u64,
327 pub sha256: String,
329}
330
331#[derive(Clone, Debug, Serialize)]
332pub struct Provenance {
334 pub id: ProvenanceId,
336 pub data: String,
338 pub source: String,
340 pub source_created_at: String,
342 pub artifacts: Vec<ProvenanceArtifact>,
344}
345
346#[derive(Clone, Debug, Serialize)]
347pub struct Stats {
352 node_count: u64,
353 full_node_characters: u64,
354 full_node_words: u64,
355 estimated_full_node_tokens: u64,
356 long_description_characters: u64,
357 long_description_words: u64,
358 estimated_long_description_tokens: u64,
359}
360
361impl Stats {
362 pub fn node_count(&self) -> u64 {
364 self.node_count
365 }
366
367 pub fn full_node_characters(&self) -> u64 {
369 self.full_node_characters
370 }
371
372 pub fn full_node_words(&self) -> u64 {
374 self.full_node_words
375 }
376
377 pub fn estimated_full_node_tokens(&self) -> u64 {
379 self.estimated_full_node_tokens
380 }
381
382 pub fn long_description_characters(&self) -> u64 {
384 self.long_description_characters
385 }
386
387 pub fn long_description_words(&self) -> u64 {
389 self.long_description_words
390 }
391
392 pub fn estimated_long_description_tokens(&self) -> u64 {
394 self.estimated_long_description_tokens
395 }
396}
397
398#[derive(Debug)]
399pub enum Error {
401 InvalidInput(String),
403 NotFound(&'static str),
405 Conflict(String),
407 Database(rusqlite::Error),
409 Io(std::io::Error),
411}
412
413impl fmt::Display for Error {
414 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
415 match self {
416 Self::InvalidInput(message) | Self::Conflict(message) => formatter.write_str(message),
417 Self::NotFound(kind) => write!(formatter, "{kind} not found."),
418 Self::Database(error) => write!(formatter, "Kmap database error: {error}"),
419 Self::Io(error) => write!(formatter, "Kmap artifact storage error: {error}"),
420 }
421 }
422}
423
424impl std::error::Error for Error {}
425
426impl From<rusqlite::Error> for Error {
427 fn from(error: rusqlite::Error) -> Self {
428 Self::Database(error)
429 }
430}
431
432impl From<std::io::Error> for Error {
433 fn from(error: std::io::Error) -> Self {
434 Self::Io(error)
435 }
436}
437
438#[derive(Clone, Debug)]
439struct ArtifactStore {
440 root: PathBuf,
441}
442
443#[derive(Clone, Debug)]
444struct StoredArtifact {
445 relative_path: String,
446 original_filename: String,
447 media_type: String,
448 byte_length: u64,
449 sha256: [u8; 32],
450}
451
452impl ArtifactStore {
453 fn new(root: impl AsRef<Path>) -> Self {
454 Self {
455 root: root.as_ref().to_owned(),
456 }
457 }
458
459 fn root(&self) -> &Path {
460 &self.root
461 }
462
463 fn store(
464 &self,
465 suffix: &str,
466 original_filename: &str,
467 media_type: &str,
468 data: &[u8],
469 ) -> Result<StoredArtifact, Error> {
470 let original_filename = validate_artifact_filename(original_filename)?;
471 let media_type = validate_media_type(media_type)?;
472 let stored_filename = suffixed_filename(&original_filename, suffix)?;
473 let shard = suffix
474 .get(..2)
475 .ok_or_else(|| Error::InvalidInput("Artifact suffix is too short.".into()))?;
476 let relative_path = format!("{shard}/{stored_filename}");
477 let directory = self.root.join(shard);
478 create_private_directory(&self.root)?;
479 create_private_directory(&directory)?;
480 let final_path = directory.join(&stored_filename);
481 let sha256: [u8; 32] = Sha256::digest(data).into();
482 if final_path.exists() {
483 verify_artifact(&final_path, data.len() as u64, sha256)?;
484 } else {
485 let temporary_suffix = URL_SAFE_NO_PAD.encode(&IdempotencyId::random().0[..9]);
486 let temporary = directory.join(format!(".{stored_filename}.{temporary_suffix}.tmp"));
487 let mut file = OpenOptions::new()
488 .write(true)
489 .create_new(true)
490 .open(&temporary)?;
491 set_file_private(&file)?;
492 let write_result = (|| -> Result<(), Error> {
493 file.write_all(data)?;
494 file.sync_all()?;
495 fs::rename(&temporary, &final_path)?;
496 sync_directory(&directory)?;
497 Ok(())
498 })();
499 if write_result.is_err() {
500 let _ = fs::remove_file(&temporary);
501 }
502 write_result?;
503 }
504 Ok(StoredArtifact {
505 relative_path,
506 original_filename,
507 media_type,
508 byte_length: data.len() as u64,
509 sha256,
510 })
511 }
512
513 fn read(&self, relative_path: &str) -> Result<Vec<u8>, Error> {
514 let path = resolve_artifact_path(&self.root, relative_path)?;
515 fs::read(path).map_err(Error::from)
516 }
517}
518
519pub struct Kmap {
523 db: Connection,
524 artifacts: ArtifactStore,
525}
526
527impl Kmap {
528 pub fn open(path: impl AsRef<Path>) -> Result<Self, Error> {
534 let path = path.as_ref();
535 let artifact_path = default_artifact_path(path);
536 Self::open_with_artifacts(path, artifact_path)
537 }
538
539 pub fn open_with_artifacts(
541 path: impl AsRef<Path>,
542 artifact_path: impl AsRef<Path>,
543 ) -> Result<Self, Error> {
544 let db = Connection::open(path)?;
545 db.execute_batch(
546 "PRAGMA foreign_keys=ON; PRAGMA journal_mode=WAL; PRAGMA busy_timeout=5000;",
547 )?;
548 db.execute_batch(SCHEMA)?;
549 Ok(Self {
550 db,
551 artifacts: ArtifactStore::new(artifact_path),
552 })
553 }
554
555 pub fn artifact_path(&self) -> &Path {
557 self.artifacts.root()
558 }
559
560 pub fn create_provenance(
566 &mut self,
567 idempotency_id: IdempotencyId,
568 input: NewProvenance,
569 ) -> Result<ProvenanceId, Error> {
570 self.create_provenance_internal(idempotency_id, input, None, Vec::new())
571 }
572
573 pub fn create_provenance_with_storage(
578 &mut self,
579 idempotency_id: IdempotencyId,
580 input: NewProvenance,
581 storage: ProvenanceStorage,
582 ) -> Result<ProvenanceId, Error> {
583 self.create_provenance_internal(
584 idempotency_id,
585 input,
586 Some(storage.data_filename),
587 storage.artifacts,
588 )
589 }
590
591 fn create_provenance_internal(
592 &mut self,
593 idempotency_id: IdempotencyId,
594 input: NewProvenance,
595 data_filename: Option<String>,
596 artifacts: Vec<NewProvenanceArtifact>,
597 ) -> Result<ProvenanceId, Error> {
598 let source = input.source.trim().to_owned();
599 if source.is_empty() || source.chars().count() > 200 {
600 return Err(Error::InvalidInput(
601 "Source must contain 1 to 200 characters.".into(),
602 ));
603 }
604 DateTime::parse_from_rfc3339(&input.source_created_at).map_err(|_| {
605 Error::InvalidInput("source_created_at must be an RFC 3339 timestamp.".into())
606 })?;
607 let data_filename = data_filename
608 .map(|value| validate_artifact_filename(&value))
609 .transpose()?;
610 let artifacts = validate_new_artifacts(artifacts)?;
611 let request_hash = provenance_request_hash(
612 &input.data,
613 &source,
614 &input.source_created_at,
615 data_filename.as_deref(),
616 &artifacts,
617 );
618 if let Some(id) = replay_receipt_on_connection(
619 &self.db,
620 idempotency_id,
621 "create_provenance",
622 request_hash,
623 )? {
624 return Ok(id);
625 }
626 let externalize_data = input.data.len() > INLINE_PROVENANCE_LIMIT;
627 let mut stored = Vec::with_capacity(artifacts.len() + usize::from(externalize_data));
628 for (index, artifact) in artifacts.iter().enumerate() {
629 stored.push((
630 artifact.role.clone(),
631 self.artifacts.store(
632 &artifact_suffix(idempotency_id, index as u64),
633 &artifact.original_filename,
634 &artifact.media_type,
635 &artifact.data,
636 )?,
637 ));
638 }
639 let data_artifact = if externalize_data {
640 let filename =
641 data_filename.unwrap_or_else(|| default_data_filename(&source, &input.data));
642 Some(self.artifacts.store(
643 &artifact_suffix(idempotency_id, artifacts.len() as u64),
644 &filename,
645 if looks_like_json(&input.data) {
646 "application/json"
647 } else {
648 "text/plain; charset=utf-8"
649 },
650 input.data.as_bytes(),
651 )?)
652 } else {
653 None
654 };
655 let tx = self
656 .db
657 .transaction_with_behavior(TransactionBehavior::Immediate)?;
658 if let Some(id) = replay_receipt(&tx, idempotency_id, "create_provenance", request_hash)? {
659 tx.commit()?;
660 return Ok(id);
661 }
662 let id = NodeId::random();
663 for (_, artifact) in &stored {
664 insert_artifact(&tx, artifact)?;
665 }
666 if let Some(artifact) = &data_artifact {
667 insert_artifact(&tx, artifact)?;
668 }
669 tx.execute(
670 "INSERT INTO data_provenance_nodes(id,data,data_artifact_path,source,source_created_at) VALUES(?1,?2,?3,?4,?5)",
671 params![
672 id.as_bytes(),
673 (!externalize_data).then_some(input.data),
674 data_artifact.as_ref().map(|artifact| artifact.relative_path.as_str()),
675 source,
676 input.source_created_at
677 ],
678 )?;
679 for (position, (role, artifact)) in stored.iter().enumerate() {
680 link_artifact(&tx, id, artifact, role, position)?;
681 }
682 if let Some(artifact) = &data_artifact {
683 link_artifact(&tx, id, artifact, "source-data", stored.len())?;
684 }
685 record_receipt(&tx, idempotency_id, "create_provenance", request_hash, id)?;
686 tx.commit()?;
687 Ok(id)
688 }
689
690 pub fn create_node(
695 &mut self,
696 idempotency_id: IdempotencyId,
697 input: CreateNode,
698 ) -> Result<Node, Error> {
699 let (short_name, short_description, model_attribution) = validate_node_input(
700 &input.short_name,
701 &input.short_description,
702 &input.long_description,
703 &input.model_attribution,
704 )?;
705 validate_connection_lists(&input.fixed_connections, &input.recent_connections)?;
706 let owner = resolve_owner(input.owner, input.id);
707 let request_hash = node_request_hash(
708 input.id,
709 input.provenance_id,
710 owner,
711 &short_name,
712 &short_description,
713 &input.long_description,
714 &model_attribution,
715 &input.fixed_connections,
716 &input.recent_connections,
717 );
718 let tx = self
719 .db
720 .transaction_with_behavior(TransactionBehavior::Immediate)?;
721 if let Some(id) = replay_receipt(&tx, idempotency_id, "create_node", request_hash)? {
722 tx.commit()?;
723 return self.get_node(id);
724 }
725 require_provenance(&tx, input.provenance_id)?;
726 require_owner(&tx, owner, input.id)?;
727 require_connections(&tx, &input.fixed_connections, input.id)?;
728 require_connections(&tx, &input.recent_connections, input.id)?;
729 let modified_at = Utc::now().to_rfc3339();
730 let history_id = NodeId::random();
731 tx.execute(
732 "INSERT INTO knowledge_nodes(id,short_name,short_description,long_description,history_head_id,owner_node_id,last_modified_by,last_modified_at) VALUES(?1,?2,?3,?4,NULL,?5,?6,?7)",
733 params![input.id.as_bytes(), short_name, short_description, input.long_description, owner.map(|id| id.0.to_vec()), model_attribution, modified_at],
734 ).map_err(map_constraint("Knowledge node already exists."))?;
735 tx.execute(
736 "INSERT INTO data_history_nodes(id,knowledge_node_id,previous_history_id,provenance_id) VALUES(?1,?2,NULL,?3)",
737 params![history_id.as_bytes(), input.id.as_bytes(), input.provenance_id.as_bytes()],
738 )?;
739 tx.execute(
740 "UPDATE knowledge_nodes SET history_head_id=?1 WHERE id=?2",
741 params![history_id.as_bytes(), input.id.as_bytes()],
742 )?;
743 replace_connections(
744 &tx,
745 input.id,
746 &input.fixed_connections,
747 &input.recent_connections,
748 )?;
749 record_receipt(&tx, idempotency_id, "create_node", request_hash, input.id)?;
750 tx.commit()?;
751 self.get_node(input.id)
752 }
753
754 pub fn update_node(
759 &mut self,
760 idempotency_id: IdempotencyId,
761 id: NodeId,
762 input: UpdateNode,
763 ) -> Result<Node, Error> {
764 let (short_name, short_description, model_attribution) = validate_node_input(
765 &input.short_name,
766 &input.short_description,
767 &input.long_description,
768 &input.model_attribution,
769 )?;
770 validate_connection_lists(&input.fixed_connections, &input.recent_connections)?;
771 let owner = resolve_owner(input.owner, id);
772 let request_hash = node_request_hash(
773 id,
774 input.provenance_id,
775 owner,
776 &short_name,
777 &short_description,
778 &input.long_description,
779 &model_attribution,
780 &input.fixed_connections,
781 &input.recent_connections,
782 );
783 let tx = self
784 .db
785 .transaction_with_behavior(TransactionBehavior::Immediate)?;
786 if let Some(result_id) = replay_receipt(&tx, idempotency_id, "update_node", request_hash)? {
787 tx.commit()?;
788 return self.get_node(result_id);
789 }
790 require_provenance(&tx, input.provenance_id)?;
791 let previous = tx
792 .query_row(
793 "SELECT history_head_id FROM knowledge_nodes WHERE id=?1",
794 [id.as_bytes()],
795 |row| row.get::<_, Option<Vec<u8>>>(0),
796 )
797 .optional()?
798 .ok_or(Error::NotFound("Knowledge node"))?;
799 require_owner(&tx, owner, id)?;
800 require_connections(&tx, &input.fixed_connections, id)?;
801 require_connections(&tx, &input.recent_connections, id)?;
802 let modified_at = Utc::now().to_rfc3339();
803 let history_id = NodeId::random();
804 tx.execute(
805 "INSERT INTO data_history_nodes(id,knowledge_node_id,previous_history_id,provenance_id) VALUES(?1,?2,?3,?4)",
806 params![history_id.as_bytes(), id.as_bytes(), previous, input.provenance_id.as_bytes()],
807 )?;
808 tx.execute(
809 "UPDATE knowledge_nodes SET short_name=?1,short_description=?2,long_description=?3,history_head_id=?4,owner_node_id=?5,last_modified_by=?6,last_modified_at=?7 WHERE id=?8",
810 params![short_name, short_description, input.long_description, history_id.as_bytes(), owner.map(|id| id.0.to_vec()), model_attribution, modified_at, id.as_bytes()],
811 )?;
812 replace_connections(&tx, id, &input.fixed_connections, &input.recent_connections)?;
813 record_receipt(&tx, idempotency_id, "update_node", request_hash, id)?;
814 tx.commit()?;
815 self.get_node(id)
816 }
817
818 pub fn get_node(&self, id: NodeId) -> Result<Node, Error> {
820 let row = self
821 .db
822 .query_row(
823 "SELECT short_name,short_description,long_description,last_modified_by,last_modified_at,owner_node_id FROM knowledge_nodes WHERE id=?1",
824 [id.as_bytes()],
825 |row| {
826 Ok((
827 row.get::<_, String>(0)?,
828 row.get::<_, String>(1)?,
829 row.get::<_, String>(2)?,
830 row.get::<_, String>(3)?,
831 row.get::<_, String>(4)?,
832 row.get::<_, Option<Vec<u8>>>(5)?,
833 ))
834 },
835 )
836 .optional()?
837 .ok_or(Error::NotFound("Knowledge node"))?;
838 Ok(Node {
839 id,
840 short_name: row.0,
841 short_description: row.1,
842 long_description: row.2,
843 last_modified_by: row.3,
844 last_modified_at: row.4,
845 owner_node_id: row.5.map(NodeId::from_blob).transpose()?,
846 fixed_connections: read_connections(&self.db, "fixed_connections", id)?,
847 recent_connections: read_connections(&self.db, "recent_connections", id)?,
848 })
849 }
850
851 pub fn get_provenance(&self, id: ProvenanceId) -> Result<Provenance, Error> {
856 let (data, data_artifact_path, source, source_created_at) = self
857 .db
858 .query_row(
859 "SELECT data,data_artifact_path,source,source_created_at FROM data_provenance_nodes WHERE id=?1",
860 [id.as_bytes()],
861 |row| {
862 Ok((
863 row.get::<_, Option<String>>(0)?,
864 row.get::<_, Option<String>>(1)?,
865 row.get::<_, String>(2)?,
866 row.get::<_, String>(3)?,
867 ))
868 },
869 )
870 .optional()?
871 .ok_or(Error::NotFound("Provenance"))?;
872 let data = match (data, data_artifact_path) {
873 (Some(data), None) => data,
874 (None, Some(path)) => String::from_utf8(self.artifacts.read(&path)?).map_err(|_| {
875 Error::Conflict("Provenance source artifact is not valid UTF-8.".into())
876 })?,
877 _ => {
878 return Err(Error::Conflict(
879 "Provenance has invalid inline/artifact storage state.".into(),
880 ));
881 }
882 };
883 Ok(Provenance {
884 id,
885 data,
886 source,
887 source_created_at,
888 artifacts: read_provenance_artifacts(&self.db, id)?,
889 })
890 }
891
892 pub fn get_node_history(&self, id: NodeId) -> Result<Vec<ProvenanceId>, Error> {
896 let mut current = self
897 .db
898 .query_row(
899 "SELECT history_head_id FROM knowledge_nodes WHERE id=?1",
900 [id.as_bytes()],
901 |row| row.get::<_, Option<Vec<u8>>>(0),
902 )
903 .optional()?
904 .ok_or(Error::NotFound("Knowledge node"))?;
905 let mut seen = HashSet::new();
906 let mut provenance_ids = Vec::new();
907 while let Some(history_blob) = current {
908 let history_id = NodeId::from_blob(history_blob)?;
909 if !seen.insert(history_id) {
910 return Err(Error::Conflict("History chain contains a cycle.".into()));
911 }
912 let (previous, provenance) = self.db.query_row(
913 "SELECT previous_history_id,provenance_id FROM data_history_nodes WHERE id=?1 AND knowledge_node_id=?2",
914 params![history_id.as_bytes(), id.as_bytes()],
915 |row| Ok((row.get::<_, Option<Vec<u8>>>(0)?, row.get::<_, Vec<u8>>(1)?)),
916 )?;
917 provenance_ids.push(NodeId::from_blob(provenance)?);
918 current = previous;
919 }
920 Ok(provenance_ids)
921 }
922
923 pub fn stats(&self) -> Result<Stats, Error> {
925 let mut statement = self
926 .db
927 .prepare("SELECT short_name,short_description,long_description FROM knowledge_nodes")?;
928 let rows = statement.query_map([], |row| {
929 Ok((
930 row.get::<_, String>(0)?,
931 row.get::<_, String>(1)?,
932 row.get::<_, String>(2)?,
933 ))
934 })?;
935 let mut stats = Stats {
936 node_count: 0,
937 full_node_characters: 0,
938 full_node_words: 0,
939 estimated_full_node_tokens: 0,
940 long_description_characters: 0,
941 long_description_words: 0,
942 estimated_long_description_tokens: 0,
943 };
944 for row in rows {
945 let (name, short, long) = row?;
946 stats.node_count = stats.node_count.saturating_add(1);
947 stats.full_node_characters = stats
948 .full_node_characters
949 .saturating_add(count_characters(&name))
950 .saturating_add(count_characters(&short))
951 .saturating_add(count_characters(&long))
952 .saturating_add(2);
953 stats.full_node_words = stats
954 .full_node_words
955 .saturating_add(count_words(&name))
956 .saturating_add(count_words(&short))
957 .saturating_add(count_words(&long));
958 stats.long_description_characters = stats
959 .long_description_characters
960 .saturating_add(count_characters(&long));
961 stats.long_description_words = stats
962 .long_description_words
963 .saturating_add(count_words(&long));
964 }
965 stats.estimated_full_node_tokens = stats.full_node_characters.div_ceil(4);
966 stats.estimated_long_description_tokens = stats.long_description_characters.div_ceil(4);
967 Ok(stats)
968 }
969}
970
971fn default_artifact_path(database: &Path) -> PathBuf {
972 if database == Path::new(":memory:") {
973 return std::env::temp_dir().join(format!(
974 "kweb-provenance-artifacts-{}",
975 URL_SAFE_NO_PAD.encode(&IdempotencyId::random().0[..9])
976 ));
977 }
978 let parent = database.parent().unwrap_or_else(|| Path::new("."));
979 let stem = database
980 .file_stem()
981 .and_then(|value| value.to_str())
982 .filter(|value| !value.is_empty())
983 .unwrap_or("kweb");
984 parent.join(format!("{stem}-provenance-artifacts"))
985}
986
987fn validate_artifact_filename(value: &str) -> Result<String, Error> {
988 if value.is_empty() || value.len() > 220 || value.contains(['/', '\\']) {
989 return Err(Error::InvalidInput(
990 "Artifact filenames must be non-empty basenames no longer than 220 bytes.".into(),
991 ));
992 }
993 let mut components = Path::new(value).components();
994 if !matches!(components.next(), Some(Component::Normal(_))) || components.next().is_some() {
995 return Err(Error::InvalidInput(
996 "Artifact filenames must contain exactly one safe path component.".into(),
997 ));
998 }
999 Ok(value.to_owned())
1000}
1001
1002fn validate_media_type(value: &str) -> Result<String, Error> {
1003 let value = value.trim();
1004 if value.is_empty() || value.len() > 200 || value.chars().any(char::is_control) {
1005 return Err(Error::InvalidInput(
1006 "Artifact media types must contain 1 to 200 printable characters.".into(),
1007 ));
1008 }
1009 Ok(value.to_owned())
1010}
1011
1012fn validate_artifact_role(value: &str) -> Result<String, Error> {
1013 let value = value.trim();
1014 if value.is_empty() || value.chars().count() > 100 {
1015 return Err(Error::InvalidInput(
1016 "Artifact roles must contain 1 to 100 characters.".into(),
1017 ));
1018 }
1019 Ok(value.to_owned())
1020}
1021
1022fn validate_new_artifacts(
1023 artifacts: Vec<NewProvenanceArtifact>,
1024) -> Result<Vec<NewProvenanceArtifact>, Error> {
1025 artifacts
1026 .into_iter()
1027 .map(|artifact| {
1028 Ok(NewProvenanceArtifact {
1029 original_filename: validate_artifact_filename(&artifact.original_filename)?,
1030 media_type: validate_media_type(&artifact.media_type)?,
1031 role: validate_artifact_role(&artifact.role)?,
1032 data: artifact.data,
1033 })
1034 })
1035 .collect()
1036}
1037
1038fn artifact_suffix(idempotency_id: IdempotencyId, position: u64) -> String {
1039 let mut hash = Sha256::new();
1040 hash.update(b"kweb-provenance-artifact-name-v1\0");
1041 hash.update(idempotency_id.as_bytes());
1042 hash.update(position.to_be_bytes());
1043 let digest = hash.finalize();
1044 URL_SAFE_NO_PAD.encode(&digest[..9])
1045}
1046
1047fn suffixed_filename(original: &str, suffix: &str) -> Result<String, Error> {
1048 let path = Path::new(original);
1049 let extension = path.extension().and_then(|value| value.to_str());
1050 let stem = path
1051 .file_stem()
1052 .and_then(|value| value.to_str())
1053 .ok_or_else(|| Error::InvalidInput("Artifact filename is not valid UTF-8.".into()))?;
1054 let filename = match extension {
1055 Some(extension) if !extension.is_empty() => format!("{stem}.{suffix}.{extension}"),
1056 _ => format!("{original}.{suffix}"),
1057 };
1058 if filename.len() > 255 {
1059 return Err(Error::InvalidInput(
1060 "Artifact filename is too long after adding its random suffix.".into(),
1061 ));
1062 }
1063 Ok(filename)
1064}
1065
1066fn default_data_filename(source: &str, data: &str) -> String {
1067 let safe_source = source
1068 .chars()
1069 .map(|character| {
1070 if character.is_ascii_alphanumeric() || matches!(character, '-' | '_') {
1071 character
1072 } else {
1073 '-'
1074 }
1075 })
1076 .collect::<String>();
1077 let extension = if looks_like_json(data) { "json" } else { "txt" };
1078 format!("{safe_source}-provenance.{extension}")
1079}
1080
1081fn looks_like_json(data: &str) -> bool {
1082 let data = data.trim();
1083 (data.starts_with('{') && data.ends_with('}')) || (data.starts_with('[') && data.ends_with(']'))
1084}
1085
1086fn resolve_artifact_path(root: &Path, relative_path: &str) -> Result<PathBuf, Error> {
1087 let mut components = Path::new(relative_path).components();
1088 let shard = match components.next() {
1089 Some(Component::Normal(value)) => value,
1090 _ => {
1091 return Err(Error::Conflict(
1092 "Stored artifact path has an invalid shard.".into(),
1093 ));
1094 }
1095 };
1096 let filename = match components.next() {
1097 Some(Component::Normal(value)) if components.next().is_none() => value,
1098 _ => {
1099 return Err(Error::Conflict(
1100 "Stored artifact path has an invalid filename.".into(),
1101 ));
1102 }
1103 };
1104 let shard = shard
1105 .to_str()
1106 .filter(|value| value.len() == 2)
1107 .ok_or_else(|| Error::Conflict("Stored artifact shard is invalid.".into()))?;
1108 let filename = filename
1109 .to_str()
1110 .ok_or_else(|| Error::Conflict("Stored artifact filename is invalid.".into()))?;
1111 Ok(root.join(shard).join(filename))
1112}
1113
1114fn verify_artifact(path: &Path, byte_length: u64, sha256: [u8; 32]) -> Result<(), Error> {
1115 let metadata = fs::metadata(path)?;
1116 if !metadata.is_file() || metadata.len() != byte_length {
1117 return Err(Error::Conflict(format!(
1118 "Existing artifact {} does not match its idempotent write.",
1119 path.display()
1120 )));
1121 }
1122 let mut file = File::open(path)?;
1123 let mut digest = Sha256::new();
1124 let mut buffer = [0_u8; 64 * 1024];
1125 loop {
1126 let read = file.read(&mut buffer)?;
1127 if read == 0 {
1128 break;
1129 }
1130 digest.update(&buffer[..read]);
1131 }
1132 if <[u8; 32]>::from(digest.finalize()) != sha256 {
1133 return Err(Error::Conflict(format!(
1134 "Existing artifact {} failed its idempotent hash check.",
1135 path.display()
1136 )));
1137 }
1138 Ok(())
1139}
1140
1141fn create_private_directory(path: &Path) -> Result<(), Error> {
1142 fs::create_dir_all(path)?;
1143 #[cfg(unix)]
1144 {
1145 use std::os::unix::fs::PermissionsExt;
1146 fs::set_permissions(path, fs::Permissions::from_mode(0o700))?;
1147 }
1148 Ok(())
1149}
1150
1151fn set_file_private(file: &File) -> Result<(), Error> {
1152 #[cfg(unix)]
1153 {
1154 use std::os::unix::fs::PermissionsExt;
1155 file.set_permissions(fs::Permissions::from_mode(0o600))?;
1156 }
1157 Ok(())
1158}
1159
1160fn sync_directory(path: &Path) -> Result<(), Error> {
1161 File::open(path)?.sync_all()?;
1162 Ok(())
1163}
1164
1165fn insert_artifact(tx: &Transaction<'_>, artifact: &StoredArtifact) -> Result<(), Error> {
1166 let byte_length = i64::try_from(artifact.byte_length)
1167 .map_err(|_| Error::InvalidInput("Artifact is too large for SQLite metadata.".into()))?;
1168 tx.execute(
1169 "INSERT INTO provenance_artifacts(relative_path,original_filename,media_type,byte_length,sha256,created_at) VALUES(?1,?2,?3,?4,?5,?6)",
1170 params![
1171 artifact.relative_path,
1172 artifact.original_filename,
1173 artifact.media_type,
1174 byte_length,
1175 artifact.sha256.as_slice(),
1176 Utc::now().to_rfc3339()
1177 ],
1178 )?;
1179 Ok(())
1180}
1181
1182fn link_artifact(
1183 tx: &Transaction<'_>,
1184 provenance_id: ProvenanceId,
1185 artifact: &StoredArtifact,
1186 role: &str,
1187 position: usize,
1188) -> Result<(), Error> {
1189 tx.execute(
1190 "INSERT INTO provenance_artifact_links(provenance_id,artifact_path,role,position) VALUES(?1,?2,?3,?4)",
1191 params![
1192 provenance_id.as_bytes(),
1193 artifact.relative_path,
1194 role,
1195 position as i64
1196 ],
1197 )?;
1198 Ok(())
1199}
1200
1201fn read_provenance_artifacts(
1202 db: &Connection,
1203 provenance_id: ProvenanceId,
1204) -> Result<Vec<ProvenanceArtifact>, Error> {
1205 let mut statement = db.prepare(
1206 "SELECT a.relative_path,a.original_filename,a.media_type,l.role,a.byte_length,a.sha256
1207 FROM provenance_artifact_links l
1208 JOIN provenance_artifacts a ON a.relative_path=l.artifact_path
1209 WHERE l.provenance_id=?1 ORDER BY l.position",
1210 )?;
1211 Ok(statement
1212 .query_map([provenance_id.as_bytes()], |row| {
1213 let byte_length = row.get::<_, i64>(4)?;
1214 Ok(ProvenanceArtifact {
1215 relative_path: row.get(0)?,
1216 original_filename: row.get(1)?,
1217 media_type: row.get(2)?,
1218 role: row.get(3)?,
1219 byte_length: u64::try_from(byte_length)
1220 .map_err(|_| rusqlite::Error::IntegralValueOutOfRange(4, byte_length))?,
1221 sha256: hex::encode(row.get::<_, Vec<u8>>(5)?),
1222 })
1223 })?
1224 .collect::<Result<Vec<_>, _>>()?)
1225}
1226
1227fn replay_receipt_on_connection(
1228 db: &Connection,
1229 id: IdempotencyId,
1230 operation_kind: &'static str,
1231 request_hash: [u8; 32],
1232) -> Result<Option<NodeId>, Error> {
1233 let receipt = db
1234 .query_row(
1235 "SELECT operation_kind,request_hash,result_id FROM idempotency_receipts WHERE id=?1",
1236 [id.as_bytes()],
1237 |row| {
1238 Ok((
1239 row.get::<_, String>(0)?,
1240 row.get::<_, Vec<u8>>(1)?,
1241 row.get::<_, Vec<u8>>(2)?,
1242 ))
1243 },
1244 )
1245 .optional()?;
1246 let Some((stored_kind, stored_hash, result_id)) = receipt else {
1247 return Ok(None);
1248 };
1249 if stored_kind != operation_kind || stored_hash.as_slice() != request_hash {
1250 return Err(Error::Conflict(
1251 "Idempotency identifier was already used for a different mutation.".into(),
1252 ));
1253 }
1254 Ok(Some(NodeId::from_blob(result_id)?))
1255}
1256
1257fn validate_node_input(
1258 name: &str,
1259 short: &str,
1260 long: &str,
1261 attribution: &str,
1262) -> Result<(String, String, String), Error> {
1263 let name = name.trim().to_owned();
1264 let short = short.trim().to_owned();
1265 let attribution = attribution.trim().to_owned();
1266 if !(4..=50).contains(&name.chars().count()) {
1267 return Err(Error::InvalidInput(
1268 "Short name must contain 4 to 50 characters.".into(),
1269 ));
1270 }
1271 if short.chars().count() > 200 {
1272 return Err(Error::InvalidInput(
1273 "Short description must not exceed 200 characters.".into(),
1274 ));
1275 }
1276 if long.split_whitespace().count() > 1000 {
1277 return Err(Error::InvalidInput(
1278 "Long description must not exceed 1000 words.".into(),
1279 ));
1280 }
1281 if attribution.is_empty() || attribution.chars().count() > 200 {
1282 return Err(Error::InvalidInput(
1283 "model_attribution must contain 1 to 200 characters.".into(),
1284 ));
1285 }
1286 Ok((name, short, attribution))
1287}
1288
1289fn validate_connection_lists(fixed: &[NodeId], recent: &[NodeId]) -> Result<(), Error> {
1290 for (name, ids) in [("fixed_connections", fixed), ("recent_connections", recent)] {
1291 let unique = ids.iter().collect::<HashSet<_>>();
1292 if unique.len() != ids.len() {
1293 return Err(Error::InvalidInput(format!(
1294 "{name} must not contain duplicate identifiers."
1295 )));
1296 }
1297 }
1298 Ok(())
1299}
1300
1301fn resolve_owner(owner: Owner, node_id: NodeId) -> Option<NodeId> {
1302 match owner {
1303 Owner::Unowned => None,
1304 Owner::SelfNode => Some(node_id),
1305 Owner::Node(id) => Some(id),
1306 }
1307}
1308
1309fn replay_receipt(
1310 tx: &Transaction<'_>,
1311 id: IdempotencyId,
1312 operation_kind: &'static str,
1313 request_hash: [u8; 32],
1314) -> Result<Option<NodeId>, Error> {
1315 let receipt = tx
1316 .query_row(
1317 "SELECT operation_kind,request_hash,result_id FROM idempotency_receipts WHERE id=?1",
1318 [id.as_bytes()],
1319 |row| {
1320 Ok((
1321 row.get::<_, String>(0)?,
1322 row.get::<_, Vec<u8>>(1)?,
1323 row.get::<_, Vec<u8>>(2)?,
1324 ))
1325 },
1326 )
1327 .optional()?;
1328 let Some((stored_kind, stored_hash, result_id)) = receipt else {
1329 return Ok(None);
1330 };
1331 if stored_kind != operation_kind || stored_hash.as_slice() != request_hash {
1332 return Err(Error::Conflict(
1333 "Idempotency identifier was already used for a different mutation.".into(),
1334 ));
1335 }
1336 Ok(Some(NodeId::from_blob(result_id)?))
1337}
1338
1339fn record_receipt(
1340 tx: &Transaction<'_>,
1341 id: IdempotencyId,
1342 operation_kind: &'static str,
1343 request_hash: [u8; 32],
1344 result_id: NodeId,
1345) -> Result<(), Error> {
1346 tx.execute(
1347 "INSERT INTO idempotency_receipts(id,operation_kind,request_hash,result_id,committed_at) VALUES(?1,?2,?3,?4,?5)",
1348 params![
1349 id.as_bytes(),
1350 operation_kind,
1351 request_hash.as_slice(),
1352 result_id.as_bytes(),
1353 Utc::now().to_rfc3339(),
1354 ],
1355 )?;
1356 Ok(())
1357}
1358
1359struct RequestHasher(Sha256);
1360
1361impl RequestHasher {
1362 fn new() -> Self {
1363 Self(Sha256::new())
1364 }
1365
1366 fn field(&mut self, value: &[u8]) {
1367 self.0.update((value.len() as u64).to_be_bytes());
1368 self.0.update(value);
1369 }
1370
1371 fn id(&mut self, value: NodeId) {
1372 self.field(value.as_bytes());
1373 }
1374
1375 fn optional_id(&mut self, value: Option<NodeId>) {
1376 match value {
1377 Some(value) => {
1378 self.field(&[1]);
1379 self.id(value);
1380 }
1381 None => self.field(&[0]),
1382 }
1383 }
1384
1385 fn ids(&mut self, values: &[NodeId]) {
1386 self.field(&(values.len() as u64).to_be_bytes());
1387 for value in values {
1388 self.id(*value);
1389 }
1390 }
1391
1392 fn finish(self) -> [u8; 32] {
1393 self.0.finalize().into()
1394 }
1395}
1396
1397fn provenance_request_hash(
1398 data: &str,
1399 source: &str,
1400 source_created_at: &str,
1401 data_filename: Option<&str>,
1402 artifacts: &[NewProvenanceArtifact],
1403) -> [u8; 32] {
1404 let mut hash = RequestHasher::new();
1405 hash.field(data.as_bytes());
1406 hash.field(source.as_bytes());
1407 hash.field(source_created_at.as_bytes());
1408 match data_filename {
1409 Some(value) => {
1410 hash.field(&[1]);
1411 hash.field(value.as_bytes());
1412 }
1413 None => hash.field(&[0]),
1414 }
1415 hash.field(&(artifacts.len() as u64).to_be_bytes());
1416 for artifact in artifacts {
1417 hash.field(artifact.original_filename.as_bytes());
1418 hash.field(artifact.media_type.as_bytes());
1419 hash.field(artifact.role.as_bytes());
1420 hash.field(Sha256::digest(&artifact.data).as_slice());
1421 }
1422 hash.finish()
1423}
1424
1425#[allow(clippy::too_many_arguments)]
1426fn node_request_hash(
1427 id: NodeId,
1428 provenance_id: ProvenanceId,
1429 owner: Option<NodeId>,
1430 short_name: &str,
1431 short_description: &str,
1432 long_description: &str,
1433 model_attribution: &str,
1434 fixed_connections: &[NodeId],
1435 recent_connections: &[NodeId],
1436) -> [u8; 32] {
1437 let mut hash = RequestHasher::new();
1438 hash.id(id);
1439 hash.id(provenance_id);
1440 hash.optional_id(owner);
1441 hash.field(short_name.as_bytes());
1442 hash.field(short_description.as_bytes());
1443 hash.field(long_description.as_bytes());
1444 hash.field(model_attribution.as_bytes());
1445 hash.ids(fixed_connections);
1446 hash.ids(recent_connections);
1447 hash.finish()
1448}
1449
1450fn require_provenance(tx: &Transaction<'_>, id: ProvenanceId) -> Result<(), Error> {
1451 let exists: bool = tx.query_row(
1452 "SELECT EXISTS(SELECT 1 FROM data_provenance_nodes WHERE id=?1)",
1453 [id.as_bytes()],
1454 |row| row.get(0),
1455 )?;
1456 if exists {
1457 Ok(())
1458 } else {
1459 Err(Error::NotFound("Provenance"))
1460 }
1461}
1462
1463fn require_owner(
1464 tx: &Transaction<'_>,
1465 owner: Option<NodeId>,
1466 creating: NodeId,
1467) -> Result<(), Error> {
1468 let Some(owner) = owner else {
1469 return Ok(());
1470 };
1471 if owner == creating {
1472 return Ok(());
1473 }
1474 let exists: bool = tx.query_row(
1475 "SELECT EXISTS(SELECT 1 FROM knowledge_nodes WHERE id=?1)",
1476 [owner.as_bytes()],
1477 |row| row.get(0),
1478 )?;
1479 if exists {
1480 Ok(())
1481 } else {
1482 Err(Error::NotFound("Owner knowledge node"))
1483 }
1484}
1485
1486fn require_connections(
1487 tx: &Transaction<'_>,
1488 ids: &[NodeId],
1489 creating: NodeId,
1490) -> Result<(), Error> {
1491 for id in ids {
1492 if *id == creating {
1493 continue;
1494 }
1495 let exists: bool = tx.query_row(
1496 "SELECT EXISTS(SELECT 1 FROM knowledge_nodes WHERE id=?1)",
1497 [id.as_bytes()],
1498 |row| row.get(0),
1499 )?;
1500 if !exists {
1501 return Err(Error::NotFound("Connected knowledge node"));
1502 }
1503 }
1504 Ok(())
1505}
1506
1507fn replace_connections(
1508 tx: &Transaction<'_>,
1509 source: NodeId,
1510 fixed: &[NodeId],
1511 recent: &[NodeId],
1512) -> Result<(), Error> {
1513 for table in ["fixed_connections", "recent_connections"] {
1514 tx.execute(
1515 &format!("DELETE FROM {table} WHERE source_node_id=?1"),
1516 [source.as_bytes()],
1517 )?;
1518 }
1519 for (table, ids) in [("fixed_connections", fixed), ("recent_connections", recent)] {
1520 for (position, target) in ids.iter().enumerate() {
1521 tx.execute(
1522 &format!(
1523 "INSERT INTO {table}(source_node_id,target_node_id,position) VALUES(?1,?2,?3)"
1524 ),
1525 params![source.as_bytes(), target.as_bytes(), position as i64],
1526 )?;
1527 }
1528 }
1529 Ok(())
1530}
1531
1532fn read_connections(db: &Connection, table: &str, source: NodeId) -> Result<Vec<NodeId>, Error> {
1533 let mut statement = db.prepare(&format!(
1534 "SELECT target_node_id FROM {table} WHERE source_node_id=?1 ORDER BY position"
1535 ))?;
1536 Ok(statement
1537 .query_map([source.as_bytes()], |row| {
1538 NodeId::from_blob(row.get::<_, Vec<u8>>(0)?)
1539 })?
1540 .collect::<Result<Vec<_>, _>>()?)
1541}
1542
1543fn map_constraint(message: &'static str) -> impl FnOnce(rusqlite::Error) -> Error {
1544 move |error| {
1545 if error.sqlite_error_code() == Some(rusqlite::ErrorCode::ConstraintViolation) {
1546 Error::Conflict(message.into())
1547 } else {
1548 Error::Database(error)
1549 }
1550 }
1551}
1552
1553fn count_characters(value: &str) -> u64 {
1554 u64::try_from(value.chars().count()).unwrap_or(u64::MAX)
1555}
1556
1557fn count_words(value: &str) -> u64 {
1558 u64::try_from(value.split_whitespace().count()).unwrap_or(u64::MAX)
1559}
1560
1561#[cfg(test)]
1562mod tests {
1563 use super::*;
1564
1565 fn kmap() -> Kmap {
1566 Kmap::open(":memory:").unwrap()
1567 }
1568
1569 fn provenance(kmap: &mut Kmap, data: &str) -> ProvenanceId {
1570 kmap.create_provenance(
1571 IdempotencyId::random(),
1572 NewProvenance {
1573 data: data.into(),
1574 source: "test".into(),
1575 source_created_at: "2026-07-18T00:00:00Z".into(),
1576 },
1577 )
1578 .unwrap()
1579 }
1580
1581 fn create(kmap: &mut Kmap, id: NodeId, provenance_id: ProvenanceId, name: &str) -> Node {
1582 kmap.create_node(
1583 IdempotencyId::random(),
1584 CreateNode {
1585 id,
1586 provenance_id,
1587 owner: Owner::SelfNode,
1588 short_name: name.into(),
1589 short_description: String::new(),
1590 long_description: String::new(),
1591 model_attribution: "test-model".into(),
1592 fixed_connections: vec![],
1593 recent_connections: vec![],
1594 },
1595 )
1596 .unwrap()
1597 }
1598
1599 #[test]
1600 fn creates_updates_and_reads_history() {
1601 let mut kmap = kmap();
1602 let created_from = provenance(&mut kmap, "created");
1603 let updated_from = provenance(&mut kmap, "updated");
1604 let id = NodeId::random();
1605 create(&mut kmap, id, created_from, "Test Node");
1606 let updated = kmap
1607 .update_node(
1608 IdempotencyId::random(),
1609 id,
1610 UpdateNode {
1611 provenance_id: updated_from,
1612 owner: Owner::SelfNode,
1613 short_name: "Updated Node".into(),
1614 short_description: "Updated.".into(),
1615 long_description: "Complete updated text.".into(),
1616 model_attribution: "new-model".into(),
1617 fixed_connections: vec![],
1618 recent_connections: vec![],
1619 },
1620 )
1621 .unwrap();
1622 assert_eq!(updated.short_name, "Updated Node");
1623 assert_eq!(updated.owner_node_id, Some(id));
1624 assert_eq!(
1625 kmap.get_node_history(id).unwrap(),
1626 vec![updated_from, created_from]
1627 );
1628 }
1629
1630 #[test]
1631 fn required_idempotency_ids_replay_mutations_and_reject_reuse() {
1632 let mut kmap = kmap();
1633 let provenance_operation = IdempotencyId::random();
1634 let provenance_input = NewProvenance {
1635 data: "source material".into(),
1636 source: "test".into(),
1637 source_created_at: "2026-07-18T00:00:00Z".into(),
1638 };
1639 let provenance_id = kmap
1640 .create_provenance(provenance_operation, provenance_input.clone())
1641 .unwrap();
1642 assert_eq!(
1643 kmap.create_provenance(provenance_operation, provenance_input.clone())
1644 .unwrap(),
1645 provenance_id
1646 );
1647
1648 let node_id = NodeId::random();
1649 let create_operation = IdempotencyId::random();
1650 let create_input = CreateNode {
1651 id: node_id,
1652 provenance_id,
1653 owner: Owner::SelfNode,
1654 short_name: "Idempotent Node".into(),
1655 short_description: String::new(),
1656 long_description: "Initial state.".into(),
1657 model_attribution: "test-model".into(),
1658 fixed_connections: vec![],
1659 recent_connections: vec![],
1660 };
1661 kmap.create_node(create_operation, create_input.clone())
1662 .unwrap();
1663 kmap.create_node(create_operation, create_input).unwrap();
1664 assert_eq!(kmap.get_node_history(node_id).unwrap().len(), 1);
1665
1666 let update_operation = IdempotencyId::random();
1667 let update_input = UpdateNode {
1668 provenance_id,
1669 owner: Owner::SelfNode,
1670 short_name: "Idempotent Node".into(),
1671 short_description: "Updated once.".into(),
1672 long_description: "Updated state.".into(),
1673 model_attribution: "test-model".into(),
1674 fixed_connections: vec![],
1675 recent_connections: vec![],
1676 };
1677 let first = kmap
1678 .update_node(update_operation, node_id, update_input.clone())
1679 .unwrap();
1680 let replay = kmap
1681 .update_node(update_operation, node_id, update_input.clone())
1682 .unwrap();
1683 assert_eq!(first.last_modified_at, replay.last_modified_at);
1684 assert_eq!(kmap.get_node_history(node_id).unwrap().len(), 2);
1685
1686 let mut changed_input = update_input;
1687 changed_input.long_description = "Different state.".into();
1688 assert!(matches!(
1689 kmap.update_node(update_operation, node_id, changed_input),
1690 Err(Error::Conflict(_))
1691 ));
1692 assert_eq!(kmap.get_node_history(node_id).unwrap().len(), 2);
1693 let receipt_count: i64 = kmap
1694 .db
1695 .query_row("SELECT COUNT(*) FROM idempotency_receipts", [], |row| {
1696 row.get(0)
1697 })
1698 .unwrap();
1699 assert_eq!(receipt_count, 3);
1700 }
1701
1702 #[test]
1703 fn externalizes_provenance_and_preserves_suffixed_original_filenames() {
1704 let directory = std::env::temp_dir().join(format!(
1705 "kweb-artifact-test-{}",
1706 URL_SAFE_NO_PAD.encode(IdempotencyId::random().0)
1707 ));
1708 let database = directory.join("kweb.sqlite3");
1709 let artifacts = directory.join("kweb-provenance-artifacts");
1710 fs::create_dir(&directory).unwrap();
1711 let mut kmap = Kmap::open_with_artifacts(&database, &artifacts).unwrap();
1712 let operation = IdempotencyId::random();
1713 let input = NewProvenance {
1714 data: "x".repeat(INLINE_PROVENANCE_LIMIT + 1),
1715 source: "conversation-history".into(),
1716 source_created_at: "2026-07-18T00:00:00Z".into(),
1717 };
1718 let storage = ProvenanceStorage {
1719 data_filename: "conversation-archive.json".into(),
1720 artifacts: vec![NewProvenanceArtifact {
1721 original_filename: "telegram-vnote.wav".into(),
1722 media_type: "audio/wav".into(),
1723 role: "media".into(),
1724 data: b"voice note".to_vec(),
1725 }],
1726 };
1727 let id = kmap
1728 .create_provenance_with_storage(operation, input.clone(), storage.clone())
1729 .unwrap();
1730 assert_eq!(
1731 kmap.create_provenance_with_storage(operation, input.clone(), storage)
1732 .unwrap(),
1733 id
1734 );
1735
1736 let provenance = kmap.get_provenance(id).unwrap();
1737 assert_eq!(provenance.data, input.data);
1738 assert_eq!(provenance.artifacts.len(), 2);
1739 let media = &provenance.artifacts[0];
1740 assert_eq!(media.original_filename, "telegram-vnote.wav");
1741 assert_eq!(media.role, "media");
1742 let (shard, filename) = media.relative_path.split_once('/').unwrap();
1743 let suffix = filename
1744 .strip_prefix("telegram-vnote.")
1745 .unwrap()
1746 .strip_suffix(".wav")
1747 .unwrap();
1748 assert_eq!(suffix.len(), 12);
1749 assert!(
1750 suffix
1751 .bytes()
1752 .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_'))
1753 );
1754 assert_eq!(shard, &suffix[..2]);
1755 assert_eq!(
1756 fs::read(artifacts.join(&media.relative_path)).unwrap(),
1757 b"voice note"
1758 );
1759 assert_eq!(provenance.artifacts[1].role, "source-data");
1760 let artifact_count: i64 = kmap
1761 .db
1762 .query_row("SELECT COUNT(*) FROM provenance_artifacts", [], |row| {
1763 row.get(0)
1764 })
1765 .unwrap();
1766 assert_eq!(artifact_count, 2);
1767 let externalized: bool = kmap
1768 .db
1769 .query_row(
1770 "SELECT data IS NULL AND data_artifact_path IS NOT NULL FROM data_provenance_nodes WHERE id=?1",
1771 [id.as_bytes()],
1772 |row| row.get(0),
1773 )
1774 .unwrap();
1775 assert!(externalized);
1776 drop(kmap);
1777 fs::remove_dir_all(directory).unwrap();
1778 }
1779
1780 #[test]
1781 fn stores_connection_ids_without_connection_policy() {
1782 let mut kmap = kmap();
1783 let source = provenance(&mut kmap, "source");
1784 let first = NodeId::random();
1785 let second = NodeId::random();
1786 create(&mut kmap, first, source, "First Node");
1787 create(&mut kmap, second, source, "Second Node");
1788 let parent = NodeId::random();
1789 let node = kmap
1790 .create_node(
1791 IdempotencyId::random(),
1792 CreateNode {
1793 id: parent,
1794 provenance_id: source,
1795 owner: Owner::SelfNode,
1796 short_name: "Parent Node".into(),
1797 short_description: String::new(),
1798 long_description: String::new(),
1799 model_attribution: "test-model".into(),
1800 fixed_connections: vec![second],
1801 recent_connections: vec![first, second],
1802 },
1803 )
1804 .unwrap();
1805 assert_eq!(node.fixed_connections, vec![second]);
1806 assert_eq!(node.recent_connections, vec![first, second]);
1807 }
1808
1809 #[test]
1810 fn stats_are_typed_and_serialize_additively() {
1811 let mut kmap = kmap();
1812 let source = provenance(&mut kmap, "source");
1813 kmap.create_node(
1814 IdempotencyId::random(),
1815 CreateNode {
1816 id: NodeId::random(),
1817 provenance_id: source,
1818 owner: Owner::SelfNode,
1819 short_name: "Alpha".into(),
1820 short_description: "Short note".into(),
1821 long_description: "Long description here".into(),
1822 model_attribution: "test-model".into(),
1823 fixed_connections: vec![],
1824 recent_connections: vec![],
1825 },
1826 )
1827 .unwrap();
1828 kmap.create_node(
1829 IdempotencyId::random(),
1830 CreateNode {
1831 id: NodeId::random(),
1832 provenance_id: source,
1833 owner: Owner::SelfNode,
1834 short_name: "Unicode 🦀".into(),
1835 short_description: "Second".into(),
1836 long_description: "More words".into(),
1837 model_attribution: "test-model".into(),
1838 fixed_connections: vec![],
1839 recent_connections: vec![],
1840 },
1841 )
1842 .unwrap();
1843 let stats = kmap.stats().unwrap();
1844 assert_eq!(stats.node_count(), 2);
1845 assert_eq!(stats.full_node_characters(), 65);
1846 assert_eq!(stats.full_node_words(), 11);
1847 assert_eq!(stats.estimated_full_node_tokens(), 17);
1848 assert_eq!(stats.long_description_characters(), 31);
1849 assert_eq!(stats.long_description_words(), 5);
1850 assert_eq!(stats.estimated_long_description_tokens(), 8);
1851 let json = serde_json::to_value(stats).unwrap();
1852 assert_eq!(json["node_count"], 2);
1853 assert!(json.get("estimated_full_node_tokens").is_some());
1854 }
1855}