Skip to main content

kweb_db_core/
lib.rs

1//! Storage-only primitives for a provenance-backed knowledge web.
2//!
3//! [`Kmap`] owns one SQLite database and a sibling immutable-artifact store.
4//! It records current knowledge nodes, append-only provenance history, ordered
5//! connection identifier arrays, and permanent mutation-idempotency receipts.
6//! The caller owns concurrency, graph policy, user/root mappings, HTTP, and all
7//! multi-call orchestration.
8//!
9//! # Minimal example
10//!
11//! ```
12//! use kweb_db_core::{IdempotencyId, Kmap, NewProvenance};
13//!
14//! let mut kweb = Kmap::open(":memory:")?;
15//! let provenance_id = kweb.create_provenance(
16//!     IdempotencyId::random(),
17//!     NewProvenance {
18//!         data: "Imported source material".into(),
19//!         source: "example".into(),
20//!         source_created_at: "2026-07-18T00:00:00Z".into(),
21//!     },
22//! )?;
23//! assert_eq!(kweb.get_provenance(provenance_id)?.data, "Imported source material");
24//! # Ok::<(), kweb_db_core::Error>(())
25//! ```
26//!
27//! See the crate README and `Specification.md` in the published package for
28//! validation rules, persistence details, and deliberate limitations.
29
30#![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)]
51/// A durable 20-byte knowledge-node identifier.
52///
53/// Its display, parsing, and Serde representation is exactly 40 lowercase
54/// hexadecimal characters.
55pub struct NodeId([u8; 20]);
56
57impl NodeId {
58    /// Generates a cryptographically random identifier.
59    pub fn random() -> Self {
60        let mut bytes = [0_u8; 20];
61        rand::rng().fill_bytes(&mut bytes);
62        Self(bytes)
63    }
64
65    /// Parses exactly 40 lowercase hexadecimal characters.
66    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    /// Returns the 40-character lowercase hexadecimal representation.
85    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
131/// The identifier of an immutable provenance record.
132pub type ProvenanceId = NodeId;
133
134#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
135/// A caller-generated 16-byte identifier for one logical mutation.
136///
137/// Reuse this value only when retrying the exact same operation and semantic
138/// input. Reusing it for another request returns [`Error::Conflict`].
139pub struct IdempotencyId([u8; 16]);
140
141impl IdempotencyId {
142    /// Generates a cryptographically random idempotency identifier.
143    pub fn random() -> Self {
144        let mut bytes = [0_u8; 16];
145        rand::rng().fill_bytes(&mut bytes);
146        Self(bytes)
147    }
148
149    /// Parses exactly 32 lowercase hexadecimal characters.
150    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    /// Returns the 32-character lowercase hexadecimal representation.
169    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)]
204/// The complete current state of a knowledge node.
205pub struct Node {
206    /// Durable node identifier.
207    pub id: NodeId,
208    /// Trimmed display name containing 4–50 Unicode characters.
209    pub short_name: String,
210    /// Trimmed summary containing at most 200 Unicode characters.
211    pub short_description: String,
212    /// Full caller-controlled description containing at most 1,000 words.
213    pub long_description: String,
214    /// Opaque caller attribution for the latest mutation.
215    pub last_modified_by: String,
216    /// Library-generated RFC 3339 timestamp of the latest mutation.
217    pub last_modified_at: String,
218    /// Owner node, self, or `None` for an explicitly unowned node.
219    pub owner_node_id: Option<NodeId>,
220    /// Ordered fixed-connection identifiers with no library-assigned policy.
221    pub fixed_connections: Vec<NodeId>,
222    /// Ordered recent-connection identifiers with no library-assigned policy.
223    pub recent_connections: Vec<NodeId>,
224}
225
226#[derive(Clone, Copy, Debug)]
227/// Ownership state supplied during a node create or update.
228pub enum Owner {
229    /// Store no owner.
230    Unowned,
231    /// Resolve ownership to the node being created or updated.
232    SelfNode,
233    /// Store another existing node as the owner.
234    Node(NodeId),
235}
236
237#[derive(Clone, Debug)]
238/// Complete input for [`Kmap::create_node`].
239pub struct CreateNode {
240    /// Caller-selected durable node identifier.
241    pub id: NodeId,
242    /// Existing provenance supporting the initial node state.
243    pub provenance_id: ProvenanceId,
244    /// Requested ownership state.
245    pub owner: Owner,
246    /// Trimmed display name containing 4–50 Unicode characters.
247    pub short_name: String,
248    /// Trimmed summary containing at most 200 Unicode characters.
249    pub short_description: String,
250    /// Full description containing at most 1,000 words.
251    pub long_description: String,
252    /// Opaque 1–200 character caller attribution.
253    pub model_attribution: String,
254    /// Complete ordered replacement array of fixed connections.
255    pub fixed_connections: Vec<NodeId>,
256    /// Complete ordered replacement array of recent connections.
257    pub recent_connections: Vec<NodeId>,
258}
259
260#[derive(Clone, Debug)]
261/// Complete replacement input for [`Kmap::update_node`].
262pub struct UpdateNode {
263    /// Existing provenance supporting the new node state.
264    pub provenance_id: ProvenanceId,
265    /// Requested ownership state.
266    pub owner: Owner,
267    /// Trimmed display name containing 4–50 Unicode characters.
268    pub short_name: String,
269    /// Trimmed summary containing at most 200 Unicode characters.
270    pub short_description: String,
271    /// Full description containing at most 1,000 words.
272    pub long_description: String,
273    /// Opaque 1–200 character caller attribution.
274    pub model_attribution: String,
275    /// Complete ordered replacement array of fixed connections.
276    pub fixed_connections: Vec<NodeId>,
277    /// Complete ordered replacement array of recent connections.
278    pub recent_connections: Vec<NodeId>,
279}
280
281#[derive(Clone, Debug, Deserialize)]
282/// Input for creating one immutable provenance record.
283pub struct NewProvenance {
284    /// Arbitrary UTF-8 source material.
285    pub data: String,
286    /// Trimmed source label containing 1–200 characters.
287    pub source: String,
288    /// Caller-supplied RFC 3339 creation time of the source material.
289    pub source_created_at: String,
290}
291
292#[derive(Clone, Debug)]
293/// One immutable byte artifact attached to new provenance.
294pub struct NewProvenanceArtifact {
295    /// Safe original basename to preserve in the stored filename.
296    pub original_filename: String,
297    /// Printable media type containing 1–200 characters.
298    pub media_type: String,
299    /// Caller-defined semantic role containing 1–100 characters.
300    pub role: String,
301    /// Complete artifact bytes.
302    pub data: Vec<u8>,
303}
304
305#[derive(Clone, Debug)]
306/// Artifact-storage input for [`Kmap::create_provenance_with_storage`].
307pub struct ProvenanceStorage {
308    /// Basename used if the main provenance text is stored externally.
309    pub data_filename: String,
310    /// Ordered immutable artifacts attached to the provenance.
311    pub artifacts: Vec<NewProvenanceArtifact>,
312}
313
314#[derive(Clone, Debug, Serialize)]
315/// Metadata for one stored provenance artifact.
316pub struct ProvenanceArtifact {
317    /// Two-component path relative to [`Kmap::artifact_path`].
318    pub relative_path: String,
319    /// Original caller-supplied basename.
320    pub original_filename: String,
321    /// Caller-supplied media type.
322    pub media_type: String,
323    /// Semantic role within the provenance record.
324    pub role: String,
325    /// Exact stored byte length.
326    pub byte_length: u64,
327    /// Lowercase hexadecimal SHA-256 digest of the stored bytes.
328    pub sha256: String,
329}
330
331#[derive(Clone, Debug, Serialize)]
332/// One immutable provenance record and its ordered artifact metadata.
333pub struct Provenance {
334    /// Durable provenance identifier.
335    pub id: ProvenanceId,
336    /// Complete UTF-8 source material, loaded transparently when external.
337    pub data: String,
338    /// Trimmed source label.
339    pub source: String,
340    /// Caller-supplied RFC 3339 source creation time.
341    pub source_created_at: String,
342    /// Ordered metadata for attached artifacts and external source data.
343    pub artifacts: Vec<ProvenanceArtifact>,
344}
345
346#[derive(Clone, Debug, Serialize)]
347/// Additively extensible statistics over current knowledge-node text.
348///
349/// Fields are private so new metrics can be added without breaking Rust callers.
350/// Serialized consumers must ignore unknown fields.
351pub 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    /// Returns the number of current knowledge nodes.
363    pub fn node_count(&self) -> u64 {
364        self.node_count
365    }
366
367    /// Returns Unicode-character count across all three node text fields.
368    pub fn full_node_characters(&self) -> u64 {
369        self.full_node_characters
370    }
371
372    /// Returns whitespace-delimited word count across all node text fields.
373    pub fn full_node_words(&self) -> u64 {
374        self.full_node_words
375    }
376
377    /// Returns the full-text character count divided by four, rounded up.
378    pub fn estimated_full_node_tokens(&self) -> u64 {
379        self.estimated_full_node_tokens
380    }
381
382    /// Returns Unicode-character count for long descriptions only.
383    pub fn long_description_characters(&self) -> u64 {
384        self.long_description_characters
385    }
386
387    /// Returns whitespace-delimited word count for long descriptions only.
388    pub fn long_description_words(&self) -> u64 {
389        self.long_description_words
390    }
391
392    /// Returns the long-description character count divided by four, rounded up.
393    pub fn estimated_long_description_tokens(&self) -> u64 {
394        self.estimated_long_description_tokens
395    }
396}
397
398#[derive(Debug)]
399/// Error returned by Kweb DB Core storage operations.
400pub enum Error {
401    /// Caller input failed validation.
402    InvalidInput(String),
403    /// A required stored object was not found.
404    NotFound(&'static str),
405    /// Stored state or idempotency reuse conflicts with the request.
406    Conflict(String),
407    /// SQLite returned an unexpected error.
408    Database(rusqlite::Error),
409    /// Filesystem artifact storage returned an unexpected error.
410    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
519/// A serialized handle to one Kweb DB Core SQLite database and artifact directory.
520///
521/// The caller must serialize access and must not expect multi-call atomicity.
522pub struct Kmap {
523    db: Connection,
524    artifacts: ArtifactStore,
525}
526
527impl Kmap {
528    /// Opens or creates a database and derives its sibling artifact directory.
529    ///
530    /// `kweb.sqlite3` derives `kweb-provenance-artifacts`. This enables foreign
531    /// keys, WAL journaling, a five-second busy timeout, and initializes the
532    /// current schema when opening a new database.
533    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    /// Opens or creates a database with an explicit artifact-directory path.
540    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    /// Returns the configured artifact-directory path.
556    pub fn artifact_path(&self) -> &Path {
557        self.artifacts.root()
558    }
559
560    /// Creates immutable provenance without explicit attached artifacts.
561    ///
562    /// Main data larger than 256 KiB is stored externally. An exact replay of
563    /// `idempotency_id` and `input` returns the original identifier without a
564    /// second write.
565    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    /// Creates immutable provenance with ordered attached artifacts.
574    ///
575    /// `storage.data_filename` names the main data if it crosses the 256 KiB
576    /// external-storage threshold. An exact replay returns the original ID.
577    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    /// Creates a knowledge node, its first history entry, and both connections.
691    ///
692    /// All referenced provenance, owner, and connection nodes must exist. An
693    /// exact idempotent replay adds no history and returns the current node.
694    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    /// Replaces a knowledge node and appends one provenance-history entry.
755    ///
756    /// Both connection arrays are complete replacements. An exact idempotent
757    /// replay appends no history and returns the current node.
758    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    /// Returns a complete knowledge node by durable identifier.
819    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    /// Returns immutable provenance and ordered artifact metadata.
852    ///
853    /// Externally stored main UTF-8 data is read transparently. Explicit
854    /// attached artifact bytes are not loaded into the result.
855    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    /// Returns the node's complete provenance history, newest first.
893    ///
894    /// The traversal detects a corrupt cycle and returns [`Error::Conflict`].
895    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    /// Calculates extensible statistics over current node text.
924    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}