Author SHA1 Message Date
ClawHDF5 Coding Agent 934d053f92 perf(agent): replace BM25 WAND top-k re-sort with a min-heap
top_k_scores.sort_by(...) ran over the full k-sized buffer for every
matching document that beat the running threshold (twice in the full
branch), plus another full sort on first reaching k results —
O(m·k log k) for m matching documents. Replace the Vec<f32> buffer
with a BinaryHeap<Reverse<HeapScore>> min-heap of size k, giving
O(m log k). Existing wand_returns_same_results_as_exhaustive test
confirms results are unchanged.

INT-11
2026-08-17 00:29:44 +00:00
ClawHDF5 Coding Agent 603fcf8757 perf(agent): use HashSet for eviction ID membership checks in consolidation
records.retain(|r| !evict_ids.contains(&r.id)) called Vec::contains
(linear scan) for every record against evict_ids, giving O(n·m) cost
on both Working- and Episodic-tier eviction every consolidation tick.
Build evict_ids as a HashSet for O(1) membership checks.

INT-15
2026-08-17 00:29:03 +00:00
ClawHDF5 Coding Agent d787ac04c8 perf(agent): avoid cloning working-tier records in consolidation add_memory
score_surprise only reads r.embedding by reference, so cloning every
Working-tier record's full chunk text + embedding Vec<f32> on every
add_memory call was wasted work, discarded immediately after use.
Collect Vec<&MemoryRecord> instead and change score_surprise's
signature to take &[&MemoryRecord].

INT-14
2026-08-17 00:28:55 +00:00
ClawHDF5 Coding Agent 55c3737130 fix(migrate): truncate on a char boundary in validate::truncate
truncate() sliced source.chunk (arbitrary UTF-8 from the source SQLite
database) at a raw byte offset. A multi-byte character straddling byte
40 panics with "byte index 40 is not a char boundary" instead of
producing the mismatch diagnostic the code exists to report — and this
is the default validate_hdf5 path, not test-only. Cut on the nearest
char boundary at or before 40 instead.

INT-10
2026-08-17 00:27:48 +00:00
ClawHDF5 Coding Agent 7314971fe7 security(format): add recursion-depth guard to Datatype::parse
Datatype::parse recurses into itself for Compound/Enumeration/
VariableLength/Array/Complex member and base types with no depth
counter. A message data size capped at u16::MAX (65535 bytes) allows
~8000 levels of nesting in a crafted file, enough to blow the stack —
worse on the project's no_std/embedded targets with only a few KB of
stack. Thread a depth counter through a new parse_with_depth, mirroring
object_header.rs's continuation-depth guard, and reject past 64 levels
with FormatError::NestingDepthExceeded. The public Datatype::parse
signature is unchanged.

INT-03
2026-08-17 00:27:16 +00:00
ClawHDF5 Coding Agent 864faf3656 security(format): fix unchecked-addition bounds check in symbol_table.rs
SymbolTableNode::parse used raw offset+8 arithmetic that can overflow
on a crafted v1-group B-tree leaf with a near-u64::MAX SNOD child
pointer (group_v1.rs passes such offsets through unchecked). Switch to
checked_add, matching read_offset in the same file. Also harden the
entries_start + num_symbols*entry_size computation with checked_add
for consistency, even though num_symbols being u16 already bounds
that multiply. Add regression tests.

INT-02
2026-08-17 00:26:13 +00:00
ClawHDF5 Coding Agent 73bc067fea security(format): fix unchecked-addition bounds checks in fixed_array/extensible_array
Six sites used raw `offset + N > file_data.len()` arithmetic that can
overflow on a crafted file with an address field near u64::MAX,
bypassing the bounds check before the next slice op panics. Switch to
the checked_add-based ensure_len pattern already used by local_heap.rs
and other parsers in this crate. Add regression tests for offsets near
usize::MAX in both files.

INT-01
2026-08-17 00:25:38 +00:00
Omar Sobh 122849b5a9 research: add implementation brief with 17 numbered INT items
Covers performance, security, and provenance findings across
clawhdf5-format, clawhdf5-migrate, and memory/query crates. Each item
lists target file, problem, and proposed change for the coding phase.
2026-08-17 00:22:21 +00:00
10 changed files with 608 additions and 319 deletions
+36 -20
View File
@@ -8,7 +8,28 @@
//! - Sorted posting lists by doc_id for cache-friendly access //! - Sorted posting lists by doc_id for cache-friendly access
//! - Block-Max WAND early termination //! - Block-Max WAND early termination
use std::collections::HashMap; use std::cmp::Reverse;
use std::collections::{BinaryHeap, HashMap};
/// `f32` wrapper providing a total order (via `total_cmp`) so BM25 scores can
/// be kept in a `BinaryHeap`. Scores are always finite in practice (no NaN
/// inputs reach this path), so `total_cmp`'s NaN ordering is never exercised.
#[derive(Debug, Clone, Copy, PartialEq)]
struct HeapScore(f32);
impl Eq for HeapScore {}
impl PartialOrd for HeapScore {
fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
Some(self.cmp(other))
}
}
impl Ord for HeapScore {
fn cmp(&self, other: &Self) -> std::cmp::Ordering {
self.0.total_cmp(&other.0)
}
}
/// Default BM25 term-frequency saturation parameter. /// Default BM25 term-frequency saturation parameter.
const DEFAULT_K1: f32 = 1.2; const DEFAULT_K1: f32 = 1.2;
@@ -97,9 +118,11 @@ impl BM25Index {
let total_max_contribution: f32 = max_tf_score.iter().sum(); let total_max_contribution: f32 = max_tf_score.iter().sum();
// Threshold for WAND early termination // Threshold for WAND early termination. `top_k_heap` is a min-heap of
// size k (worst-of-the-top-k at the head) so it can be maintained in
// O(log k) per update instead of re-sorting the whole buffer.
let mut threshold = 0.0f32; let mut threshold = 0.0f32;
let mut top_k_scores: Vec<f32> = Vec::with_capacity(k); let mut top_k_heap: BinaryHeap<Reverse<HeapScore>> = BinaryHeap::with_capacity(k);
for (term_idx, (_, idf, postings)) in query_terms.iter().enumerate() { for (term_idx, (_, idf, postings)) in query_terms.iter().enumerate() {
for &(doc_id, freq) in *postings { for &(doc_id, freq) in *postings {
@@ -118,24 +141,17 @@ impl BM25Index {
if term_idx == query_terms.len() - 1 { if term_idx == query_terms.len() - 1 {
// Last term: check if this doc beats threshold // Last term: check if this doc beats threshold
let final_score = *entry; let final_score = *entry;
if final_score > threshold && top_k_scores.len() >= k { if top_k_heap.len() >= k {
// Update threshold if final_score > threshold {
top_k_scores // Replace the current worst-of-top-k.
.sort_by(|a, b| b.partial_cmp(a).unwrap_or(std::cmp::Ordering::Equal)); top_k_heap.pop();
if final_score > top_k_scores[k - 1] { top_k_heap.push(Reverse(HeapScore(final_score)));
top_k_scores[k - 1] = final_score; threshold = top_k_heap.peek().map(|Reverse(s)| s.0).unwrap_or(0.0);
top_k_scores.sort_by(|a, b| {
b.partial_cmp(a).unwrap_or(std::cmp::Ordering::Equal)
});
threshold = top_k_scores[k - 1];
} }
} else if top_k_scores.len() < k { } else {
top_k_scores.push(final_score); top_k_heap.push(Reverse(HeapScore(final_score)));
if top_k_scores.len() == k { if top_k_heap.len() == k {
top_k_scores.sort_by(|a, b| { threshold = top_k_heap.peek().map(|Reverse(s)| s.0).unwrap_or(0.0);
b.partial_cmp(a).unwrap_or(std::cmp::Ordering::Equal)
});
threshold = top_k_scores[k - 1];
} }
} }
} }
+6 -6
View File
@@ -118,7 +118,7 @@ impl ImportanceScorer {
/// Novelty score: 1.0 max cosine similarity against all existing records. /// Novelty score: 1.0 max cosine similarity against all existing records.
/// Returns 1.0 when there are no existing memories. /// Returns 1.0 when there are no existing memories.
pub fn score_surprise(embedding: &[f32], existing_memories: &[MemoryRecord]) -> f32 { pub fn score_surprise(embedding: &[f32], existing_memories: &[&MemoryRecord]) -> f32 {
if existing_memories.is_empty() { if existing_memories.is_empty() {
return 1.0; return 1.0;
} }
@@ -209,11 +209,10 @@ impl ConsolidationEngine {
source: MemorySource, source: MemorySource,
now: f64, now: f64,
) -> u64 { ) -> u64 {
let working: Vec<MemoryRecord> = self let working: Vec<&MemoryRecord> = self
.records .records
.iter() .iter()
.filter(|r| r.tier == MemoryTier::Working) .filter(|r| r.tier == MemoryTier::Working)
.cloned()
.collect(); .collect();
let surprise = ImportanceScorer::score_surprise(&embedding, &working); let surprise = ImportanceScorer::score_surprise(&embedding, &working);
@@ -281,7 +280,7 @@ impl ConsolidationEngine {
if working_count > capacity { if working_count > capacity {
let evict_n = working_count - capacity; let evict_n = working_count - capacity;
// Collect the ids of the records to evict (lowest decay = first in sorted list). // Collect the ids of the records to evict (lowest decay = first in sorted list).
let evict_ids: Vec<u64> = working_indices[..evict_n] let evict_ids: std::collections::HashSet<u64> = working_indices[..evict_n]
.iter() .iter()
.map(|&i| self.records[i].id) .map(|&i| self.records[i].id)
.collect(); .collect();
@@ -342,7 +341,7 @@ impl ConsolidationEngine {
}); });
let evict_n = episodic_count - episodic_capacity; let evict_n = episodic_count - episodic_capacity;
let evict_ids: Vec<u64> = episodic_indices[..evict_n] let evict_ids: std::collections::HashSet<u64> = episodic_indices[..evict_n]
.iter() .iter()
.map(|&i| self.records[i].id) .map(|&i| self.records[i].id)
.collect(); .collect();
@@ -464,7 +463,8 @@ mod tests {
created_at: 0.0, created_at: 0.0,
source: MemorySource::User, source: MemorySource::User,
}]; }];
let score = ImportanceScorer::score_surprise(&emb, &existing); let existing_refs: Vec<&MemoryRecord> = existing.iter().collect();
let score = ImportanceScorer::score_surprise(&emb, &existing_refs);
assert!(score < 0.01, "expected ~0.0, got {score}"); assert!(score < 0.01, "expected ~0.0, got {score}");
} }
+26 -56
View File
@@ -329,47 +329,6 @@ impl KnowledgeCache {
(id, true) (id, true)
} }
// -----------------------------------------------------------------------
// Adjacency index (built fresh per traversal call — see doc comment)
// -----------------------------------------------------------------------
/// Build an O(V+R) adjacency index for one traversal call: an entity-id →
/// vec-index map for O(1) entity lookups, and an entity-id →
/// `(neighbour_id, relation_weight)` map (covering both outgoing and
/// incoming edges) for O(1) neighbour expansion. The weight is carried
/// alongside each neighbour so callers like `spreading_activation` that
/// need per-edge weight don't have to re-scan `relations`.
///
/// This is rebuilt at the start of every `bfs_neighbors`/
/// `spreading_activation` call rather than cached on the struct: `entities`
/// and `relations` are public fields, and `schema.rs`'s deserialization
/// path pushes into them directly (bypassing `add_entity`/`add_relation`),
/// so a struct-cached index could go stale. Building it once per call
/// still turns an O(V·R) (or O(steps·V·R)) traversal into O(V+R) (or
/// O(steps·(V+E))), since the old code repeated the O(R) relation scan
/// once per visited node instead of once per call.
fn build_adjacency(&self) -> (HashMap<u64, usize>, HashMap<u64, Vec<(u64, f32)>>) {
let mut entity_index: HashMap<u64, usize> = HashMap::with_capacity(self.entities.len());
for (i, e) in self.entities.iter().enumerate() {
entity_index.insert(e.id, i);
}
// Note: a self-loop relation (src == tgt) contributes a single
// neighbour entry, not two, matching the if/else-if (not two
// independent ifs) structure this replaces — otherwise a self-loop
// would be double-counted by `spreading_activation`.
let mut adjacency: HashMap<u64, Vec<(u64, f32)>> =
HashMap::with_capacity(self.relations.len());
for r in &self.relations {
adjacency.entry(r.src).or_default().push((r.tgt, r.weight));
if r.tgt != r.src {
adjacency.entry(r.tgt).or_default().push((r.src, r.weight));
}
}
(entity_index, adjacency)
}
// ----------------------------------------------------------------------- // -----------------------------------------------------------------------
// Graph traversal: BFS neighbors // Graph traversal: BFS neighbors
// ----------------------------------------------------------------------- // -----------------------------------------------------------------------
@@ -378,8 +337,6 @@ impl KnowledgeCache {
/// together with their discovered depth. The seed entity itself is NOT /// together with their discovered depth. The seed entity itself is NOT
/// included. Traversal follows both outgoing and incoming relation edges. /// included. Traversal follows both outgoing and incoming relation edges.
pub fn bfs_neighbors(&self, entity_id: u64, max_depth: usize) -> Vec<(Entity, usize)> { pub fn bfs_neighbors(&self, entity_id: u64, max_depth: usize) -> Vec<(Entity, usize)> {
let (entity_index, adjacency) = self.build_adjacency();
let mut visited: HashSet<u64> = HashSet::new(); let mut visited: HashSet<u64> = HashSet::new();
let mut queue: VecDeque<(u64, usize)> = VecDeque::new(); let mut queue: VecDeque<(u64, usize)> = VecDeque::new();
let mut results: Vec<(Entity, usize)> = Vec::new(); let mut results: Vec<(Entity, usize)> = Vec::new();
@@ -392,15 +349,25 @@ impl KnowledgeCache {
continue; continue;
} }
let Some(neighbours) = adjacency.get(&current_id) else { // Collect neighbour IDs from outgoing and incoming edges.
continue; let neighbours: Vec<u64> = self
}; .relations
.iter()
.filter_map(|r| {
if r.src == current_id {
Some(r.tgt)
} else if r.tgt == current_id {
Some(r.src)
} else {
None
}
})
.collect();
for &(neighbour_id, _weight) in neighbours { for neighbour_id in neighbours {
if visited.insert(neighbour_id) if visited.insert(neighbour_id)
&& let Some(&idx) = entity_index.get(&neighbour_id) && let Some(entity) = self.get_entity(neighbour_id)
{ {
let entity = &self.entities[idx];
results.push((entity.clone(), depth + 1)); results.push((entity.clone(), depth + 1));
queue.push_back((neighbour_id, depth + 1)); queue.push_back((neighbour_id, depth + 1));
} }
@@ -472,8 +439,6 @@ impl KnowledgeCache {
min_activation: f32, min_activation: f32,
max_steps: usize, max_steps: usize,
) -> Vec<(u64, f32)> { ) -> Vec<(u64, f32)> {
let (_entity_index, adjacency) = self.build_adjacency();
let mut activation: HashMap<u64, f32> = HashMap::new(); let mut activation: HashMap<u64, f32> = HashMap::new();
// Initialise seeds with activation 1.0. // Initialise seeds with activation 1.0.
@@ -497,11 +462,16 @@ impl KnowledgeCache {
for (source_id, source_score) in current { for (source_id, source_score) in current {
// Spread to all neighbours via outgoing and incoming edges. // Spread to all neighbours via outgoing and incoming edges.
let Some(neighbours) = adjacency.get(&source_id) else { for rel in &self.relations {
continue; let neighbour_id = if rel.src == source_id {
}; rel.tgt
for &(neighbour_id, weight) in neighbours { } else if rel.tgt == source_id {
let delta = source_score * weight * decay_factor; rel.src
} else {
continue;
};
let delta = source_score * rel.weight * decay_factor;
if delta >= min_activation { if delta >= min_activation {
*activation.entry(neighbour_id).or_insert(0.0) += delta; *activation.entry(neighbour_id).or_insert(0.0) += delta;
any_spread = true; any_spread = true;
+31 -44
View File
@@ -1,31 +1,31 @@
//! Memory provenance tracking and integrity verification. //! Memory provenance tracking and integrity verification.
//! //!
//! Records the origin, authorship, and a content hash of every memory chunk //! Records the origin, authorship, and a content hash of every memory chunk
//! so the system can detect content corruption and trace data lineage. The //! so the system can detect *accidental* corruption and trace data lineage.
//! hash is a SHA-256 digest (see [`hash_content`]), computed via //! The hash is unkeyed (see [`fnv1a_64`]) — this is not a tamper-evidence or
//! [`clawhdf5_format::provenance::sha256_hex`]. It is still **unkeyed** — an //! authenticity guarantee.
//! actor able to overwrite the stored chunk can also recompute and overwrite
//! the stored hash alongside it, so this is not an authenticity guarantee
//! against that threat. What SHA-256 does provide over a fast non-cryptographic
//! hash (the previous FNV-1a implementation) is collision resistance: an
//! adversary cannot cheaply craft *different* poisoned content that matches
//! an already-recorded legitimate hash.
use std::collections::HashMap; use std::collections::HashMap;
pub use crate::consolidation::MemorySource; pub use crate::consolidation::MemorySource;
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Hash helper // Hash helper (std-only FNV-1a 64-bit)
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
/// SHA-256 hex digest of `text`, used to detect content corruption/tampering. /// Unkeyed, non-cryptographic FNV-1a hash for detecting accidental content
/// /// corruption. It is trivially forgeable by anyone able to modify the stored
/// Unkeyed: an actor able to modify the stored chunk can also recompute and /// data, since they can recompute and overwrite the stored hash alongside
/// overwrite the stored hash, so a match is not proof of authenticity — only /// it — do not rely on this as a tamper-evidence or authenticity control.
/// that the stored chunk and stored hash are mutually consistent. fn fnv1a_64(text: &str) -> u64 {
fn hash_content(text: &str) -> String { const OFFSET: u64 = 14_695_981_039_346_656_037;
clawhdf5_format::provenance::sha256_hex(text.as_bytes()) const PRIME: u64 = 1_099_511_628_211;
let mut hash = OFFSET;
for byte in text.bytes() {
hash ^= byte as u64;
hash = hash.wrapping_mul(PRIME);
}
hash
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -57,8 +57,8 @@ pub struct MemoryProvenance {
pub created_by: String, pub created_by: String,
/// Unix timestamp (seconds) of creation. /// Unix timestamp (seconds) of creation.
pub created_at: f64, pub created_at: f64,
/// SHA-256 hex digest of the chunk text for integrity checking. /// FNV-1a 64-bit hash of the chunk text for integrity checking.
pub content_hash: String, pub content_hash: u64,
pub session_id: String, pub session_id: String,
pub verified: bool, pub verified: bool,
} }
@@ -78,7 +78,7 @@ impl MemoryProvenance {
source, source,
created_by: created_by.into(), created_by: created_by.into(),
created_at, created_at,
content_hash: hash_content(chunk), content_hash: fnv1a_64(chunk),
session_id: session_id.into(), session_id: session_id.into(),
verified: false, verified: false,
} }
@@ -121,16 +121,13 @@ impl ProvenanceStore {
/// Re-hash `current_chunk` and compare against the stored hash. /// Re-hash `current_chunk` and compare against the stored hash.
/// Returns `true` if the content matches (integrity intact). /// Returns `true` if the content matches (integrity intact).
/// ///
/// The hash is unkeyed, so an actor able to modify the stored chunk can /// This only detects accidental corruption: the hash is unkeyed, so an
/// also recompute and overwrite the stored hash. Do not treat a `true` /// actor able to modify the stored chunk can also recompute and
/// result as proof of authenticity against that threat — but unlike a /// overwrite the stored hash. Do not treat a `true` result as proof the
/// non-cryptographic hash, a `false` result reliably indicates that the /// data hasn't been tampered with.
/// content does not match what was recorded, since SHA-256 makes it
/// computationally infeasible to craft different content that collides
/// with a specific existing digest.
pub fn verify_integrity(&self, record_id: u64, current_chunk: &str) -> bool { pub fn verify_integrity(&self, record_id: u64, current_chunk: &str) -> bool {
match self.records.get(&record_id) { match self.records.get(&record_id) {
Some(p) => p.content_hash == hash_content(current_chunk), Some(p) => p.content_hash == fnv1a_64(current_chunk),
None => false, None => false,
} }
} }
@@ -244,32 +241,22 @@ mod tests {
1_700_000_000.0 1_700_000_000.0
} }
// --- hash_content --- // --- fnv1a_64 ---
#[test] #[test]
fn hash_deterministic() { fn hash_deterministic() {
assert_eq!(hash_content("hello"), hash_content("hello")); assert_eq!(fnv1a_64("hello"), fnv1a_64("hello"));
} }
#[test] #[test]
fn hash_different_inputs() { fn hash_different_inputs() {
assert_ne!(hash_content("hello"), hash_content("world")); assert_ne!(fnv1a_64("hello"), fnv1a_64("world"));
} }
#[test] #[test]
fn hash_empty() { fn hash_empty() {
// Should not panic, and should match the well-known SHA-256 of the empty string. // Should not panic
assert_eq!( let _ = fnv1a_64("");
hash_content(""),
"e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
);
}
#[test]
fn hash_is_sha256_hex() {
let h = hash_content("clawhdf5");
assert_eq!(h.len(), 64);
assert!(h.chars().all(|c| c.is_ascii_hexdigit()));
} }
// --- MemorySource Display --- // --- MemorySource Display ---
@@ -288,7 +275,7 @@ mod tests {
#[test] #[test]
fn provenance_new_hashes_chunk() { fn provenance_new_hashes_chunk() {
let p = MemoryProvenance::new(1, MemorySource::User, "agent-1", ts(), "hello", "s1"); let p = MemoryProvenance::new(1, MemorySource::User, "agent-1", ts(), "hello", "s1");
assert_eq!(p.content_hash, hash_content("hello")); assert_eq!(p.content_hash, fnv1a_64("hello"));
assert!(!p.verified); assert!(!p.verified);
} }
+54 -7
View File
@@ -204,11 +204,25 @@ fn read_uint(data: &[u8], offset: usize, nbytes: usize) -> Result<u64, FormatErr
}) })
} }
/// Maximum recursion depth for nested datatypes (Compound/Enumeration/
/// VariableLength/Array). A crafted file can nest a message-size-capped
/// (65535 byte) datatype message ~8000 levels deep, which would blow the
/// stack — especially on the project's no_std/embedded targets where
/// available stack is a few KB.
const MAX_DATATYPE_DEPTH: u16 = 64;
impl Datatype { impl Datatype {
/// Parse a datatype message from raw bytes. /// Parse a datatype message from raw bytes.
/// ///
/// Returns `(Datatype, bytes_consumed)` for recursive parsing. /// Returns `(Datatype, bytes_consumed)` for recursive parsing.
pub fn parse(data: &[u8]) -> Result<(Datatype, usize), FormatError> { pub fn parse(data: &[u8]) -> Result<(Datatype, usize), FormatError> {
Self::parse_with_depth(data, 0)
}
fn parse_with_depth(data: &[u8], depth: u16) -> Result<(Datatype, usize), FormatError> {
if depth >= MAX_DATATYPE_DEPTH {
return Err(FormatError::NestingDepthExceeded);
}
// Minimum header: 4 bytes (class_and_version + 3 bytes bit field) + 4 bytes size = 8 // Minimum header: 4 bytes (class_and_version + 3 bytes bit field) + 4 bytes size = 8
ensure_len(data, 0, 8)?; ensure_len(data, 0, 8)?;
@@ -358,7 +372,7 @@ impl Datatype {
pos += name_len; pos += name_len;
let byte_offset = read_uint(data, pos, ob)?; let byte_offset = read_uint(data, pos, ob)?;
pos += ob; pos += ob;
let (member_dt, consumed) = Datatype::parse(&data[pos..])?; let (member_dt, consumed) = Self::parse_with_depth(&data[pos..], depth + 1)?;
pos += consumed; pos += consumed;
members.push(CompoundMember { members.push(CompoundMember {
name, name,
@@ -384,7 +398,7 @@ impl Datatype {
// dimensionality(1) + reserved(3) + dim_perm(4) + 4 dim slots(16) = 24 // dimensionality(1) + reserved(3) + dim_perm(4) + 4 dim slots(16) = 24
ensure_len(data, pos, 24)?; ensure_len(data, pos, 24)?;
pos += 24; pos += 24;
let (member_dt, consumed) = Datatype::parse(&data[pos..])?; let (member_dt, consumed) = Self::parse_with_depth(&data[pos..], depth + 1)?;
pos += consumed; pos += consumed;
members.push(CompoundMember { members.push(CompoundMember {
name, name,
@@ -415,7 +429,7 @@ impl Datatype {
// Enumeration // Enumeration
let num_members = (bf0 as u16) | ((bf1 as u16) << 8); let num_members = (bf0 as u16) | ((bf1 as u16) << 8);
// Parse base type // Parse base type
let (base_type, base_consumed) = Datatype::parse(&data[pos..])?; let (base_type, base_consumed) = Self::parse_with_depth(&data[pos..], depth + 1)?;
pos += base_consumed; pos += base_consumed;
let base_size = base_type.type_size(); let base_size = base_type.type_size();
let mut members = Vec::with_capacity(num_members as usize); let mut members = Vec::with_capacity(num_members as usize);
@@ -468,7 +482,7 @@ impl Datatype {
} else { } else {
None None
}; };
let (base_type, consumed) = Datatype::parse(&data[pos..])?; let (base_type, consumed) = Self::parse_with_depth(&data[pos..], depth + 1)?;
pos += consumed; pos += consumed;
Ok(( Ok((
Datatype::VariableLength { Datatype::VariableLength {
@@ -494,7 +508,7 @@ impl Datatype {
} }
// skip permutation indices // skip permutation indices
pos += ndims * 4; pos += ndims * 4;
let (base_type, consumed) = Datatype::parse(&data[pos..])?; let (base_type, consumed) = Self::parse_with_depth(&data[pos..], depth + 1)?;
pos += consumed; pos += consumed;
Ok(( Ok((
Datatype::Array { Datatype::Array {
@@ -515,7 +529,7 @@ impl Datatype {
dimensions.push(LittleEndian::read_u32(&data[pos..pos + 4])); dimensions.push(LittleEndian::read_u32(&data[pos..pos + 4]));
pos += 4; pos += 4;
} }
let (base_type, consumed) = Datatype::parse(&data[pos..])?; let (base_type, consumed) = Self::parse_with_depth(&data[pos..], depth + 1)?;
pos += consumed; pos += consumed;
Ok(( Ok((
Datatype::Array { Datatype::Array {
@@ -545,7 +559,7 @@ impl Datatype {
pos += name_len; pos += name_len;
let byte_offset = read_uint(data, pos, ob)?; let byte_offset = read_uint(data, pos, ob)?;
pos += ob; pos += ob;
let (member_dt, consumed) = Datatype::parse(&data[pos..])?; let (member_dt, consumed) = Self::parse_with_depth(&data[pos..], depth + 1)?;
pos += consumed; pos += consumed;
members.push(CompoundMember { members.push(CompoundMember {
name, name,
@@ -814,6 +828,39 @@ mod tests {
buf buf
} }
/// A crafted datatype message nesting Variable-Length wrappers deeper
/// than `MAX_DATATYPE_DEPTH` must return `NestingDepthExceeded`
/// instead of overflowing the stack.
#[test]
fn nested_variable_length_exceeds_depth_limit() {
// Each VL level is just an 8-byte header (class 9, vl_type=0 =>
// sequence, no padding/charset fields) immediately followed by the
// next level's bytes, terminated by a fixed-point base type.
let levels = MAX_DATATYPE_DEPTH as usize + 10;
let mut data = Vec::new();
for _ in 0..levels {
data.extend_from_slice(&build_dt_header(9, 3, [0, 0, 0], 0));
}
data.extend_from_slice(&build_fixed_point(4, false, false, 0, 32));
let result = Datatype::parse(&data);
assert!(matches!(result, Err(FormatError::NestingDepthExceeded)));
}
/// A datatype nested just within the depth limit must still parse fine.
#[test]
fn nested_variable_length_within_depth_limit_ok() {
let levels = MAX_DATATYPE_DEPTH as usize - 1;
let mut data = Vec::new();
for _ in 0..levels {
data.extend_from_slice(&build_dt_header(9, 3, [0, 0, 0], 0));
}
data.extend_from_slice(&build_fixed_point(4, false, false, 0, 32));
let result = Datatype::parse(&data);
assert!(result.is_ok());
}
#[test] #[test]
fn test_fixed_point_u8() { fn test_fixed_point_u8() {
let data = build_fixed_point(1, false, false, 0, 8); let data = build_fixed_point(1, false, false, 0, 8);
+44 -24
View File
@@ -54,6 +54,19 @@ fn read_offset(data: &[u8], pos: usize, size: u8) -> Result<u64, FormatError> {
}) })
} }
fn ensure_len(data: &[u8], offset: usize, needed: usize) -> Result<(), FormatError> {
if offset
.checked_add(needed)
.is_none_or(|end| end > data.len())
{
return Err(FormatError::UnexpectedEof {
expected: offset.saturating_add(needed),
available: data.len(),
});
}
Ok(())
}
fn is_undefined_addr(addr: u64, offset_size: u8) -> bool { fn is_undefined_addr(addr: u64, offset_size: u8) -> bool {
match offset_size { match offset_size {
2 => addr == 0xFFFF, 2 => addr == 0xFFFF,
@@ -98,12 +111,7 @@ impl ExtensibleArrayHeader {
// 6 stats fields (each length_size) + index_block_address(offset_size) + checksum(4) // 6 stats fields (each length_size) + index_block_address(offset_size) + checksum(4)
let min_size = let min_size =
4 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 6 * length_size as usize + offset_size as usize + 4; 4 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 6 * length_size as usize + offset_size as usize + 4;
if offset + min_size > file_data.len() { ensure_len(file_data, offset, min_size)?;
return Err(FormatError::UnexpectedEof {
expected: offset + min_size,
available: file_data.len(),
});
}
let d = &file_data[offset..]; let d = &file_data[offset..];
if &d[0..4] != b"EAHD" { if &d[0..4] != b"EAHD" {
@@ -275,12 +283,7 @@ fn read_data_block_elements(
) -> Result<Vec<ChunkInfo>, FormatError> { ) -> Result<Vec<ChunkInfo>, FormatError> {
// AEDB: signature(4) + version(1) + client_id(1) + header_address(offset_size) // AEDB: signature(4) + version(1) + client_id(1) + header_address(offset_size)
let db_header_size = 4 + 1 + 1 + offset_size as usize; let db_header_size = 4 + 1 + 1 + offset_size as usize;
if db_offset + db_header_size > file_data.len() { ensure_len(file_data, db_offset, db_header_size)?;
return Err(FormatError::UnexpectedEof {
expected: db_offset + db_header_size,
available: file_data.len(),
});
}
let d = &file_data[db_offset..]; let d = &file_data[db_offset..];
if &d[0..4] != b"EADB" { if &d[0..4] != b"EADB" {
@@ -427,12 +430,7 @@ pub fn read_extensible_array_chunks(
// Parse index block (AEIB) // Parse index block (AEIB)
let ib_offset = header.index_block_address as usize; let ib_offset = header.index_block_address as usize;
let ib_header_size = 4 + 1 + 1 + offset_size as usize; // sig + ver + client + hdr_addr let ib_header_size = 4 + 1 + 1 + offset_size as usize; // sig + ver + client + hdr_addr
if ib_offset + ib_header_size > file_data.len() { ensure_len(file_data, ib_offset, ib_header_size)?;
return Err(FormatError::UnexpectedEof {
expected: ib_offset + ib_header_size,
available: file_data.len(),
});
}
let ib = &file_data[ib_offset..]; let ib = &file_data[ib_offset..];
if &ib[0..4] != b"EAIB" { if &ib[0..4] != b"EAIB" {
@@ -628,12 +626,7 @@ fn read_super_block(
// AESB: signature(4) + version(1) + client_id(1) + header_address(offset_size) // AESB: signature(4) + version(1) + client_id(1) + header_address(offset_size)
let sb_header_size = 4 + 1 + 1 + os; let sb_header_size = 4 + 1 + 1 + os;
if sb_offset + sb_header_size > file_data.len() { ensure_len(file_data, sb_offset, sb_header_size)?;
return Err(FormatError::UnexpectedEof {
expected: sb_offset + sb_header_size,
available: file_data.len(),
});
}
if &file_data[sb_offset..sb_offset + 4] != b"EASB" { if &file_data[sb_offset..sb_offset + 4] != b"EASB" {
return Err(FormatError::ChunkedReadError( return Err(FormatError::ChunkedReadError(
@@ -759,6 +752,33 @@ mod tests {
assert!(result.is_err()); assert!(result.is_err());
} }
/// A near-`usize::MAX` offset must error cleanly, not overflow/panic.
#[test]
fn parse_rejects_offset_overflow() {
let buf = vec![0u8; 64];
let result = ExtensibleArrayHeader::parse(&buf, usize::MAX - 4, 8, 8);
assert!(result.is_err());
}
/// A near-`usize::MAX` index block address must error cleanly, not overflow/panic.
#[test]
fn read_rejects_index_block_offset_overflow() {
let header = ExtensibleArrayHeader {
client_id: 0,
element_size: 8,
max_nelmts_bits: 10,
idx_blk_elmts: 2,
min_dblk_nelmts: 4,
super_blk_min_nelmts: 2,
max_dblk_nelmts_bits: 8,
num_elements: 5,
index_block_address: (usize::MAX - 4) as u64,
};
let buf = vec![0u8; 64];
let r = read_extensible_array_chunks(&buf, &header, &[100], &[20], 8, 8, 8);
assert!(r.is_err());
}
#[test] #[test]
fn parse_header_invalid_version() { fn parse_header_invalid_version() {
let mut buf = vec![0u8; 256]; let mut buf = vec![0u8; 256];
+38 -12
View File
@@ -47,6 +47,19 @@ fn read_length(data: &[u8], pos: usize, size: u8) -> Result<u64, FormatError> {
read_offset(data, pos, size) read_offset(data, pos, size)
} }
fn ensure_len(data: &[u8], offset: usize, needed: usize) -> Result<(), FormatError> {
if offset
.checked_add(needed)
.is_none_or(|end| end > data.len())
{
return Err(FormatError::UnexpectedEof {
expected: offset.saturating_add(needed),
available: data.len(),
});
}
Ok(())
}
fn is_undefined(data: &[u8], pos: usize, size: u8) -> bool { fn is_undefined(data: &[u8], pos: usize, size: u8) -> bool {
let s = size as usize; let s = size as usize;
if pos + s > data.len() { if pos + s > data.len() {
@@ -66,12 +79,7 @@ impl FixedArrayHeader {
// FAHD signature(4) + version(1) + client_id(1) + element_size(1) + // FAHD signature(4) + version(1) + client_id(1) + element_size(1) +
// max_nelmts_bits(1) + num_elements(length_size) + data_block_addr(offset_size) + checksum(4) // max_nelmts_bits(1) + num_elements(length_size) + data_block_addr(offset_size) + checksum(4)
let min_size = 4 + 1 + 1 + 1 + 1 + length_size as usize + offset_size as usize + 4; let min_size = 4 + 1 + 1 + 1 + 1 + length_size as usize + offset_size as usize + 4;
if offset + min_size > file_data.len() { ensure_len(file_data, offset, min_size)?;
return Err(FormatError::UnexpectedEof {
expected: offset + min_size,
available: file_data.len(),
});
}
let d = &file_data[offset..]; let d = &file_data[offset..];
if &d[0..4] != b"FAHD" { if &d[0..4] != b"FAHD" {
@@ -126,12 +134,7 @@ pub fn read_fixed_array_chunks(
// Parse data block header: FADB(4) + version(1) + client_id(1) + header_address(offset_size) // Parse data block header: FADB(4) + version(1) + client_id(1) + header_address(offset_size)
let db_header_size = 4 + 1 + 1 + offset_size as usize; let db_header_size = 4 + 1 + 1 + offset_size as usize;
if db_offset + db_header_size > file_data.len() { ensure_len(file_data, db_offset, db_header_size)?;
return Err(FormatError::UnexpectedEof {
expected: db_offset + db_header_size,
available: file_data.len(),
});
}
let d = &file_data[db_offset..]; let d = &file_data[db_offset..];
if &d[0..4] != b"FADB" { if &d[0..4] != b"FADB" {
@@ -489,6 +492,29 @@ mod tests {
assert!(r.is_err()); assert!(r.is_err());
} }
/// A near-`usize::MAX` offset must error cleanly, not overflow/panic.
#[test]
fn parse_rejects_offset_overflow() {
let buf = vec![0u8; 64];
let result = FixedArrayHeader::parse(&buf, usize::MAX - 4, 8, 8);
assert!(result.is_err());
}
/// A near-`usize::MAX` data block address must error cleanly, not overflow/panic.
#[test]
fn read_rejects_data_block_offset_overflow() {
let header = FixedArrayHeader {
client_id: 0,
element_size: 8,
max_nelmts_bits: 10,
num_elements: 1,
data_block_address: (usize::MAX - 4) as u64,
};
let buf = vec![0u8; 64];
let r = read_fixed_array_chunks(&buf, &header, &[100], &[20], 8, 8, 8);
assert!(r.is_err());
}
#[test] #[test]
fn parse_fixed_array_header_invalid_version() { fn parse_fixed_array_header_invalid_version() {
let mut buf = vec![0u8; 256]; let mut buf = vec![0u8; 256];
+28 -3
View File
@@ -80,9 +80,9 @@ impl SymbolTableNode {
offset_size: u8, offset_size: u8,
) -> Result<SymbolTableNode, FormatError> { ) -> Result<SymbolTableNode, FormatError> {
// signature(4) + version(1) + reserved(1) + number_of_symbols(2) = 8 // signature(4) + version(1) + reserved(1) + number_of_symbols(2) = 8
if offset + 8 > file_data.len() { if offset.checked_add(8).is_none_or(|end| end > file_data.len()) {
return Err(FormatError::UnexpectedEof { return Err(FormatError::UnexpectedEof {
expected: offset + 8, expected: offset.saturating_add(8),
available: file_data.len(), available: file_data.len(),
}); });
} }
@@ -103,7 +103,12 @@ impl SymbolTableNode {
// Each entry: link_name_offset(os) + obj_hdr_addr(os) + cache_type(4) + reserved(4) + scratch(16) // Each entry: link_name_offset(os) + obj_hdr_addr(os) + cache_type(4) + reserved(4) + scratch(16)
let entry_size = os + os + 4 + 4 + 16; let entry_size = os + os + 4 + 4 + 16;
let entries_start = offset + 8; let entries_start = offset + 8;
let needed = entries_start + num_symbols * entry_size; let needed = entries_start
.checked_add(num_symbols * entry_size)
.ok_or(FormatError::UnexpectedEof {
expected: usize::MAX,
available: file_data.len(),
})?;
if needed > file_data.len() { if needed > file_data.len() {
return Err(FormatError::UnexpectedEof { return Err(FormatError::UnexpectedEof {
expected: needed, expected: needed,
@@ -228,4 +233,24 @@ mod tests {
let err = SymbolTableNode::parse(&data, 0, 8).unwrap_err(); let err = SymbolTableNode::parse(&data, 0, 8).unwrap_err();
assert_eq!(err, FormatError::InvalidSymbolTableNodeVersion(2)); assert_eq!(err, FormatError::InvalidSymbolTableNodeVersion(2));
} }
/// A near-`usize::MAX` SNOD offset must error cleanly, not overflow/panic.
#[test]
fn parse_snod_rejects_offset_overflow() {
let data = build_snod(&[], 8);
let result = SymbolTableNode::parse(&data, usize::MAX - 4, 8);
assert!(result.is_err());
}
/// A huge symbol count combined with a large entries_start must not
/// overflow the `needed` size computation.
#[test]
fn parse_snod_rejects_entries_size_overflow() {
let mut data = build_snod(&[], 8);
// num_symbols at offset 6..8 — set to max to blow up entries_start + num_symbols*entry_size
data[6] = 0xFF;
data[7] = 0xFF;
let result = SymbolTableNode::parse(&data, usize::MAX / 2, 8);
assert!(result.is_err());
}
} }
+30 -1
View File
@@ -144,7 +144,8 @@ fn truncate(s: &str) -> String {
if s.len() <= 40 { if s.len() <= 40 {
s.to_string() s.to_string()
} else { } else {
format!("{}", &s[..40]) let cut = s.char_indices().nth(40).map(|(i, _)| i).unwrap_or(s.len());
format!("{}", &s[..cut])
} }
} }
@@ -161,3 +162,31 @@ fn sample_indices(n: usize, full: bool) -> Vec<usize> {
idx.dedup(); idx.dedup();
idx idx
} }
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn truncate_short_string_unchanged() {
assert_eq!(truncate("hello"), "hello");
}
/// A multi-byte character straddling byte offset 40 must not panic a
/// byte-index slice — this is arbitrary UTF-8 chunk text from an
/// untrusted source database, not test-only input.
#[test]
fn truncate_multibyte_char_at_boundary_does_not_panic() {
// 39 ASCII bytes then a 4-byte emoji straddling the byte-40 cut point.
let s = format!("{}{}", "a".repeat(39), "😀".repeat(5));
let result = truncate(&s);
assert!(result.ends_with('…'));
assert!(result.chars().count() < s.chars().count());
}
#[test]
fn truncate_exactly_at_limit_unchanged() {
let s = "a".repeat(40);
assert_eq!(truncate(&s), s);
}
}
+315 -146
View File
@@ -1,176 +1,345 @@
# ClawHDF5 — Performance / Security / Provenance Implementation Brief # Implementation Brief — Performance, Security & Provenance
**Date:** 2026-08-16 **Phase:** Research
**Scope:** Follow-up hardening pass on top of the already-shipped Tier 1-4 work **Date:** 2026-08-17
(see `ROADMAP.md` "What's Next" and `IMPROVEMENT_LOG.md`). This brief covers **Scope:** `clawhdf5` Rust workspace (`/mission/repo`)
only items verified against the current repo state at commit `b2dce41` that
were **not** already addressed by prior tiers.
## Method ## Method
Read `ROADMAP.md`, `IMPROVEMENT_LOG.md`, `IMPROVEMENT_SCAN.md`, and Read `ROADMAP.md`, `IMPROVEMENT_LOG.md`, `CLAUDE.md`, `CHANGELOG.md`, and recent
`CLAUDE.md` first to avoid re-proposing work already merged (WAL CRC32, `git log` before scoping this brief, to avoid re-proposing work already merged.
bounds-check audit + fuzz target, HNSW `prune_connections` parallelism, The repo has already been through several hardening passes (Tier 14, see
Android JNI length validation, `workspace.dependencies` hoisting, etc. are `CHANGELOG.md` "Unreleased" section and the `git log` entries tagged
all already done — see those files for the full list). `security:`/`perf:`): bounds-check audits on `chunked_read.rs`/`data_read.rs`/
`local_heap.rs`/`btree_v1.rs`, `MAX_DECOMPRESS_SIZE` output caps, WAL v2
per-entry CRC32, Android JNI length validation, pyo3 bump, O(1) chunk-cache
lookup with `Arc`-shared buffers, and optional rayon parallelism for HNSW
`prune_connections`. None of that is re-proposed here.
Then manually audited: Four focused audits were run against the areas those passes did **not**
- `crates/clawhdf5-format/src/{chunked_read,data_read}.rs` — bounds-check cover: (1) the HDF5 binary parser files outside the already-audited set, plus
spot audit (sampled `ensure_len` call sites around every raw slice index). `clawhdf5-accel`/`clawhdf5-gpu` unsafe code; (2) `clawhdf5-agent`'s
**Result: no new gaps found.** Every raw `file_data[a..b]` site sampled is query-time hot paths (search/rerank/consolidation/knowledge graph); (3) the
preceded by an `ensure_len`/`read_offset` overflow-checked bound. The prior provenance/anomaly-detection subsystem end-to-end; (4) error handling in
Tier 4a pass already closed this out. `clawhdf5-io`, `clawhdf5-migrate`, `clawhdf5-py`, and the `clawhdf5` facade.
- `crates/clawhdf5-agent/src/provenance.rs` — memory record integrity →
**gap found**, see INT-01.
- `crates/clawhdf5-agent/src/knowledge.rs` — knowledge-graph traversal →
**gap found**, see INT-02.
- `crates/clawhdf5-agent/src/bm25.rs` — already has cached IDF, sorted
postings, WAND early termination. No changes proposed.
- `crates/clawhdf5-ann/src/hnsw.rs` — build-time parallelism already scoped
to `prune_connections` per Tier 4c; the outer insert loop is flagged in
ROADMAP as needing its own correctness-sensitive design pass, out of scope
here.
## INT-01 — Harden agent memory provenance hash from FNV-1a to SHA-256 `clawhdf5-accel` (SIMD dispatch), `clawhdf5-gpu` (no unsafe code, wgpu-mediated),
`clawhdf5-io`, `clawhdf5-py`, and the `clawhdf5` facade crate were all found
already sound for the failure modes investigated — no items proposed for
those beyond what's listed below. Say so once here rather than padding the
list with manufactured items.
**File:** `crates/clawhdf5-agent/src/provenance.rs` ---
**Category:** Security / Provenance
**Status:** Implemented this pass.
### Problem ## Section A — Parser crash safety (crafted-file DoS)
`MemoryProvenance::content_hash` used an unkeyed 64-bit FNV-1a hash These three files use raw `offset + N > file_data.len()` arithmetic instead
(`fnv1a_64`) to detect corruption of stored agent-memory chunks. FNV-1a is of the `checked_add`-based `ensure_len` helper that every other parser in
a fast non-cryptographic hash with no collision resistance: an adversary `clawhdf5-format` already uses (established pattern: `btree_v2.rs`,
attempting to plant poisoned/tampered memory content that still matches a `global_heap.rs`, `fractal_heap.rs`, `shared_message.rs`, `local_heap.rs`'s
previously-recorded or expected hash value only needs to find *any* input own `ensure_len`, etc.). On a crafted file with an address field close to
producing the same 64-bit output, which is computationally cheap for `u64::MAX`, the addition overflows — panicking in debug builds, silently
FNV-1a (no preimage or collision resistance guarantees at all). Given wrapping in the release profile (no `overflow-checks` set anywhere in the
Track 5 of `ROADMAP.md` explicitly claims "poisoning resistance" and workspace `Cargo.toml`), after which the bounds check passes falsely and the
`verify_integrity()` is the one function whose entire job is to catch next slice operation panics anyway. Net effect either way: a crafted file
tampered memory content, using a hash with no collision resistance crashes the parser instead of returning `Err`.
undermines that guarantee in a way that is easy to miss (the doc comment
already, correctly, disclaims *authenticity* — i.e. it never claimed to
stop an attacker who can also rewrite the stored hash — but it did not
protect against a weaker, still-relevant attack: crafting *different*
poisoned content that collides with an already-recorded legitimate hash).
Separately, `clawhdf5-format` already ships a mature, default-on ### INT-01 — `crates/clawhdf5-format/src/fixed_array.rs`, `crates/clawhdf5-format/src/extensible_array.rs`
`provenance` feature (`crates/clawhdf5-format/src/provenance.rs`) with a **Problem:** Six unguarded-addition bounds checks: `FixedArrayHeader::parse`
`sha256_hex()` helper built on the `sha2` crate, used for on-disk dataset (fixed_array.rs:69), the data-block header check in
provenance attributes. `clawhdf5-agent` already depends on `read_fixed_array_chunks` (fixed_array.rs:129), `ExtensibleArrayHeader::parse`
`clawhdf5-format` with default features enabled, so `sha256_hex` was (extensible_array.rs:101), `read_extensible_array_data_block`
already reachable with **zero new dependencies**. (extensible_array.rs:278), the index-block parse (extensible_array.rs:429),
and the super-block parse (extensible_array.rs:630). The offending offsets
(`data_block_address`/`index_block_address`) come from `DataLayout::parse`
(`data_layout.rs`, chunk_index_type 3/4 branches, ~lines 460470), which only
special-cases the exact all-`0xFF` sentinel via `is_undefined` — any other
near-max value passes through unchanged.
**Change:** Replace every raw `offset + N > file_data.len()` in both files
with the `checked_add`-based `ensure_len` pattern already used elsewhere in
the crate (e.g. mirror `local_heap.rs`'s `ensure_len`).
### Fix implemented ### INT-02 — `crates/clawhdf5-format/src/symbol_table.rs`
**Problem:** `SymbolTableNode::parse` (line 83) uses raw
`offset + 8 > file_data.len()`, unlike `read_offset` in the same file which
already uses `checked_add`. `offset` is a SNOD address taken verbatim from a
v1 B-tree leaf entry and passed straight through by `group_v1.rs:49` with no
sentinel/range check — a crafted v1-group B-tree leaf with a near-`u64::MAX`
child pointer overflows the check the same way as INT-01.
**Change:** Use `offset.checked_add(8)` (`ensure_len` pattern) at line 83.
Note: the `entries_start + num_symbols * entry_size` addition at line 106 has
the same raw-arithmetic style, but `num_symbols` is `u16` so the multiply
itself can't overflow — lower priority, but worth fixing for consistency in
the same pass.
- `MemoryProvenance::content_hash` changed from `u64` to `String` (lowercase ### INT-03 — `crates/clawhdf5-format/src/datatype.rs`
hex SHA-256 digest), computed via `clawhdf5_format::provenance::sha256_hex`. **Problem:** `Datatype::parse` recurses into itself with no depth counter
- `ProvenanceStore::verify_integrity` now compares SHA-256 hex digests. (`grep -n "depth" datatype.rs` — zero hits) for Compound members (lines 361,
- Removed the local `fnv1a_64` helper from `provenance.rs` (no longer used 387), Enumeration base type (line 418), VariableLength base type (line 471),
there — `clawhdf5-agent/src/multimodal.rs` keeps its own independent and Array base type (lines 497, 518). A message data size is capped at
`fnv1a_64` for `MediaRef` checksums, which is a content-identity/dedup key, `u16::MAX` (65535 bytes; see `object_header.rs:141` v1, `object_header.rs:411`
not a security/integrity control, so it is intentionally left unchanged v2), so a crafted Compound-of-Compound-of-Compound... datatype message can
and out of scope for this item). nest ~8000 levels deep — enough to blow the stack, and materially worse on
- Updated the module-level and per-item doc comments to keep the existing, the project's documented no_std/embedded targets (`thumbv7em-none-eabihf`,
correct disclaimer: this is still an **unkeyed** hash, so it is still not per `CHANGELOG.md`) where available stack is a few KB. The changelog records
an authenticity/tamper-*evidence* guarantee against an attacker who can this exact class of bug already fixed for the N-Bit filter's type tree, but
rewrite the stored hash alongside the content. What changed is that it is that fix was never applied to the general `Datatype::parse` reader used for
no longer trivially *collidable*, which was the concrete, fixable gap. every Dataspace/Attribute/Dataset datatype message.
- Updated all existing unit tests in `provenance.rs` for the new `String` **Change:** Thread a `depth: u16` counter through `Datatype::parse`'s
hash type; behavior (which records match/mismatch) is unchanged. recursive call sites (mirror `object_header.rs`'s continuation-depth guards)
and return a new `FormatError::NestingDepthExceeded` past a fixed limit
(suggest 64).
`MemoryProvenance` and `ProvenanceStore` are only used within ---
`clawhdf5-agent` itself (not serialized to the HDF5 format, not consumed by
other crates), so this is a self-contained, non-breaking-to-other-crates
change verified by `grep -r MemoryProvenance crates/`.
## INT-02 — Knowledge-graph traversal: replace O(V·R) relation scans with a per-call adjacency index ## Section B — Provenance & anomaly detection
**File:** `crates/clawhdf5-agent/src/knowledge.rs` The most significant finding of this brief: **the provenance/anomaly
**Category:** Performance subsystem exists and is tested, but is never invoked from the real save/load
**Status:** Implemented this pass. path.** It's a fully-built, unused API surface, not an active control.
### Problem ### INT-04 — `crates/clawhdf5-agent/src/provenance.rs`, `crates/clawhdf5-agent/src/anomaly.rs`, `crates/clawhdf5-agent/src/lib.rs`
**Problem:** `ProvenanceStore`, `MemoryProvenance::new`, `verify_integrity`,
`mark_verified`, `WriteAnomalyDetector`, `record_write`,
`check_pattern_anomaly`, `check_rate_anomaly`, `check_source_anomaly` have
zero callers outside their own module/tests. `lib.rs` only declares
`pub mod provenance;` / `pub mod anomaly;` (lines 22, 33) — neither is
referenced from `HDF5Memory::save_or_update` (~line 495) or the WAL replay
path (`wal.rs::replay_into_cache`, line 311). Concretely: the 15
injection-pattern checks, rate limiting, and content-hash integrity
verification described as shipped in `ROADMAP.md` Track 5 never execute
during normal library usage today.
**Change:** Call `ProvenanceStore::add` and
`WriteAnomalyDetector::record_write` + the `check_*` methods from
`HDF5Memory::save_or_update`, and call `verify_integrity` from the
open/load path (surfacing a mismatch to the caller, not panicking). If the
intent is genuinely opt-in-only, that's a legitimate design choice, but it
must be documented prominently at the crate root / in `CLAUDE.md` — right
now it reads as an active control and isn't one.
`KnowledgeCache::bfs_neighbors` and `KnowledgeCache::spreading_activation` ### INT-05 — `crates/clawhdf5-agent/src/lib.rs` (`MemoryEntry.source_channel`, ~line 167), `crates/clawhdf5-agent/src/consolidation.rs` (`ConsolidationEngine::add_memory`, ~line 205)
are the core traversal primitives behind Track 1 (BFS neighbors, subgraph **Problem:** `source_channel: String` is free text set entirely by the
extraction) and Track 3 (graph-aware re-ranking) of the agent memory caller of `save`/`save_or_update` — nothing validates it against an
system. Both did a **full linear scan over `self.relations`** for every allowlist, so a write can claim `source_channel = "system"` or any other
node processed: privileged-looking label. Separately, `add_memory` takes `source:
MemorySource` (User/System/Tool/Retrieval/Correction) as a plain parameter;
`MemorySource::Correction`/`System` get elevated importance weighting in
`score_correction` (~line 133), so any caller can claim a trust level the
content doesn't warrant.
**Change:** Derive `MemorySource`/`source_channel` at the actual trust
boundary (the ingestion layer that knows the true origin), not as a
caller-supplied argument to the storage API. At minimum, gate
`MemorySource::System`/`Correction` construction behind a distinct
constructor not exposed to the same call path as untrusted content.
- `bfs_neighbors`: for every entity dequeued from the BFS frontier, it ### INT-06 — `crates/clawhdf5-agent/src/anomaly.rs` (`check_pattern_anomaly`, ~lines 192195)
scanned the entire `relations: Vec<Relation>` looking for edges touching **Problem:** Matching is `chunk.to_lowercase().contains(pattern.as_str())`
that entity — O(V·R) instead of O(V+E). It also called plain literal-substring test after case folding only. Inserting any
`self.get_entity(neighbour_id)`, itself an O(n) linear scan over character inside a pattern (extra whitespace, a zero-width character, `.`
`entities: Vec<Entity>`, once per newly-discovered neighbour. between letters) or substituting a homoglyph for one Latin letter defeats
- `spreading_activation`: for every activated node in every propagation every one of the 15 injection patterns; there's no Unicode
step, it likewise scanned all of `self.relations` — O(steps·V·R). confusable-normalization or punctuation/whitespace stripping.
- `get_subgraph` calls `bfs_neighbors` once per seed, compounding the cost. **Change:** Normalize input before matching (strip zero-width characters and
punctuation, apply NFKC + confusable-folding) or switch to fuzzy/token-based
detection instead of raw `contains`.
For a knowledge graph with thousands of entities/relations (the scale this ### INT-07 — `crates/clawhdf5-agent/src/anomaly.rs` (`check_rate_anomaly`, ~lines 149151)
project's own benchmarks target — see `BENCHMARKS.md`), this is **Problem:** The per-minute rate check uses a single global sliding window
quadratic-ish behavior in traversal-heavy paths (`get_entity_context`, (`self.window.len()`) across all sessions/sources combined. One noisy
hybrid retrieval re-ranking that pulls graph context) that only gets worse session can trip the shared window without the alert naming the offending
as agent memory accumulates over long sessions. session (unlike the separate cumulative `max_writes_per_session` check,
which does name it); conversely, many distinct low-volume sessions can
jointly flood the shared window without any individual one tripping its own
per-session limit.
**Change:** Key the sliding window by session/source (or add a per-source
rolling count) so the rate check attributes to, and can throttle, the actual
offender.
### Fix implemented ### INT-08 — `crates/clawhdf5-format/src/provenance.rs` (`verify_dataset`, ~line 126)
**Problem:** The SHA-256 content hash is written automatically on save when
`db.provenance` is set (`file_writer.rs` ~10611068, gated on the
`provenance` feature), but `verify_dataset` is only ever called from test
files — no reader/open path in `clawhdf5-io` or the `clawhdf5` facade calls
it. A corrupted dataset is silently readable with no automatic integrity
check; the write-side machinery exists but nothing consumes it. (Note:
`CHANGELOG.md` already documents that this hash is unkeyed/tamper-*evident*
not tamper-*proof* — that's accepted and not re-flagged here; this item is
about it never being invoked at all, not about its cryptographic strength.)
**Change:** Optionally call `verify_dataset` on dataset open (behind the
`provenance` feature) and surface a mismatch as a typed error/warning to the
caller instead of leaving verification purely opt-in/manual.
Added a private helper, `KnowledgeCache::build_adjacency`, that builds, in ### INT-09 — `crates/clawhdf5-agent/src/wal.rs` (`WalFile::read_entries`, ~lines 219272)
one O(V+R) pass: **Problem:** Two related gaps. (a) WAL v2's per-entry CRC32 covers only each
- `entity_index: HashMap<u64, usize>` — entity id → index into `entities`. entry's own bytes — there's no sequence number or entry-chaining, so entries
- `adjacency: HashMap<u64, Vec<u64>>` — entity id → neighbour ids (both could be reordered, duplicated, or spliced (e.g. a `Tombstone` moved
outgoing and incoming edges). before/after its target `Save`) while every individual entry still passes
its own CRC check, silently changing replayed cache state. (b) The
`WAL_VERSION_LEGACY_NO_CRC` branch (~lines 260266) does no CRC verification
at all, and the version byte itself is a single unauthenticated byte — since
`read_entries` is a public standalone API (not just reached via `open()`'s
one-time migrate-on-read), flipping that byte from `2` to `1` silently
downgrades every subsequent entry in the file to the fully-unverified
pre-hardening parser.
**Change:** Add a monotonic sequence number or entry-chaining (CRC/hash
including the previous entry's CRC) to detect reordering/splicing. Restrict
the legacy-no-CRC branch to the `open()` migration path only, or emit a
warning when `read_entries` falls back to it via any other entry point.
`bfs_neighbors` and `spreading_activation` now build this index **once at ---
the top of the call** (not persisted as struct state — see rationale below)
and use it for O(1) neighbour/entity lookups inside the traversal loop,
changing the complexity to O(V+E) per call for BFS and O(steps·(V+E)) for
spreading activation.
**Why not a persistent index on the struct:** `entities`/`relations` are ## Section C — Correctness bug (panic on valid, untrusted input)
public fields, and `crates/clawhdf5-agent/src/schema.rs` (deserialization
path, loading a persisted knowledge graph back from HDF5) pushes directly
into `cache.entities`/`cache.relations` rather than going through
`add_entity`/`add_relation`. A struct-level cached index would silently go
stale on that path. Building the index fresh at the top of each traversal
call is O(V+R) — the same asymptotic cost as the scan it replaces would be
for a *single* node — so it turns what was an O(V·R)-or-worse *whole
traversal* into an O(V+R) traversal, with no risk of a stale-index
correctness bug and no change to the existing public API or struct layout.
`get_entity`, `get_relations_from`, `get_relations_to` are left as-is
(still O(n)/O(R)): they're public API used elsewhere as one-off lookups,
not inside a per-node hot loop, so indexing them is lower value and was
left out of scope to keep this change minimal and low-risk.
Existing tests (`test_bfs_neighbors_*`, `test_get_subgraph_*`, ### INT-10 — `crates/clawhdf5-migrate/src/validate.rs` (`truncate`, lines 143149)
`test_spreading_activation_*`) exercise correctness and were not modified — **Problem:**
they pass unchanged, confirming the traversal results are identical to the ```rust
pre-change O(V·R) implementation. fn truncate(s: &str) -> String {
if s.len() <= 40 {
s.to_string()
} else {
format!("{}", &s[..40]) // byte-index slice, not char-boundary safe
}
}
```
`s` is `source.chunk` — arbitrary UTF-8 text read from the source SQLite
database, called from the chunk-text mismatch branch of `validate_hdf5`
(~line 58) whenever migrated text doesn't exactly match the source. This is
the default (non-`--dry-run`) validation path, not test-only code — the file
has no `#[cfg(test)]` block. If a multi-byte character (emoji, accented
letter, CJK, etc.) straddles byte offset 40, `&s[..40]` panics with "byte
index 40 is not a char boundary" instead of producing the diagnostic the
code exists to report.
**Change:** Truncate on a char boundary, e.g.
`let cut = s.char_indices().nth(40).map(|(i, _)| i).unwrap_or(s.len()); format!("{}…", &s[..cut])`.
## Deferred / not implemented this pass ---
Listed for a future pass — investigated but out of scope for this brief's ## Section D — Performance (query-time hot paths, `clawhdf5-agent`)
budget, or blocked on a larger design decision already flagged upstream:
- **HNSW outer insert-loop parallelism** — `ROADMAP.md` already flags this `search.rs`, `vector_search.rs`, `hybrid.rs`, `reranker.rs`, `confidence.rs`,
as needing "its own dedicated design pass" before parallelizing; not `temporal.rs`, `ivf.rs`, `pq.rs`, and `gpu_search.rs` were reviewed and found
attempted here to avoid a correctness-sensitive change without that design already efficient (temporal index uses `partition_point` binary search,
work. hybrid merge uses `HashMap` accumulation not nested loops, no gratuitous
- **WAL per-entry format redesign** (explicit length-prefix instead of clones in the batch vector paths) — no items proposed there.
read-then-verify-CRC32) — `ROADMAP.md` already notes the current CRC32
trailer works and this would only be worth revisiting "if profiling shows
it matters"; no such profiling signal was found this pass.
- **`get_entity`/`get_relations_from`/`get_relations_to` indexing** — would
further help `get_entity_context` and any other one-off caller, but is
lower value than the hot-loop fix in INT-02 and was left out to keep this
change minimal.
## Verification performed ### INT-11 — `crates/clawhdf5-agent/src/bm25.rs` (`BM25Index::search`, ~lines 118141)
**Problem:** The WAND top-k threshold update calls
`top_k_scores.sort_by(...)` over the full `k`-sized buffer for every matching
document that beats the running threshold (twice in the `>= k` branch), plus
another full sort on reaching exactly `k` results. For `m` matching
documents this is `O(m·k log k)` where a heap gives `O(m log k)`.
**Change:** Replace `top_k_scores: Vec<f32>` with a min-heap
(`BinaryHeap<Reverse<f32>>`) of size `k`; pop/push instead of sort-and-index.
- `cargo build --workspace --lib --bins` — clean before starting (baseline). ### INT-12 — `crates/clawhdf5-agent/src/knowledge.rs` (`KnowledgeCache::resolve_or_create`, lines 304330)
- `cargo test --workspace` — run after implementing INT-01 and INT-02 (see **Problem:** `self.entities.iter().map(|e| levenshtein(&lower_name,
commit for pass/fail status). &e.name.to_lowercase()))` allocates a fresh lowercased `String` for every
entity on every resolution call (this runs per extracted mention during
entity/relation extraction) and never short-circuits even on an exact
`dist == 0` match — it scores every remaining entity regardless.
**Change:** Cache a lowercased name on `Entity` to avoid the
per-call allocation, and break out of the scan as soon as a `dist == 0`
match is found.
TASK: INT-01 — Harden agent memory provenance hash from FNV-1a to SHA-256 ### INT-13`crates/clawhdf5-agent/src/knowledge.rs` (`bfs_neighbors` lines 339378, `spreading_activation` lines 435495, `get_relations_from`/`get_relations_to` lines 247254)
TASK: INT-02 — Knowledge-graph traversal: per-call adjacency index instead of O(V·R) relation scans **Problem:** All four functions filter/scan the *entire* `self.relations`
list per node processed (`O(V·E)` for BFS instead of `O(V+E)`;
`O(max_steps · active_nodes · relations)` for spreading activation), and
`bfs_neighbors` additionally calls `self.get_entity(neighbour_id)` per
discovered neighbor, itself an `O(n)` linear `.find()` over `self.entities`.
**Change:** Build (or maintain incrementally on `add_entity`/`add_relation`)
a `HashMap<u64, Vec<usize>>` adjacency index and a `HashMap<u64, usize>`
id→index map, shared across all four functions, replacing the linear scans
with O(1)/O(degree) lookups.
### INT-14 — `crates/clawhdf5-agent/src/consolidation.rs` (`ConsolidationEngine::add_memory`, lines 212217)
**Problem:**
```rust
let working: Vec<MemoryRecord> = self.records.iter()
.filter(|r| r.tier == MemoryTier::Working)
.cloned()
.collect();
```
`score_surprise` (the only consumer) only reads `r.embedding` by reference —
the full clone (chunk text + embedding `Vec<f32>`) of every working-tier
record is discarded immediately after use.
**Change:** Collect `Vec<&MemoryRecord>` (or iterate the filtered
`self.records` directly, passing an iterator of `&[f32]`) instead of
`.cloned()`.
### INT-15 — `crates/clawhdf5-agent/src/consolidation.rs` (`consolidate`, lines 284291 and 345351)
**Problem:** `self.records.retain(|r| !evict_ids.contains(&r.id))` where
`evict_ids: Vec<u64>``retain` calls `.contains()` (linear scan) for every
record in `self.records`, giving `O(n·m)` cost (n = records, m = eviction
count) on both the Working-tier eviction (line 289) and Episodic-tier
eviction (line 350), on every consolidation tick.
**Change:** Build `evict_ids` as a `HashSet<u64>` for O(1) membership checks.
### INT-16 — `crates/clawhdf5-agent/src/blas_search.rs` (`blas_cosine_batch`, lines 3039), `crates/clawhdf5-agent/src/accelerate_search.rs` (`accelerate_cosine_batch_vecs`, lines 164173)
**Problem:** `cache.embeddings` is stored as `Vec<Vec<f32>>`; both functions
re-flatten the entire corpus into a fresh `Vec<f32>`
(`flat.extend_from_slice(&vectors[i])` per non-tombstoned vector) on *every
single query* before running the actual BLAS/Accelerate matmul — an
`O(N·dim)` copy paid per query when the `fast-math` feature is enabled. The
fix pattern already exists in-file: `blas_cosine_batch_flat` (same file,
lines 89142) has an `all_active` fast path that skips this copy when
reading from a pre-flattened buffer directly — it's just not used for the
`Vec<Vec<f32>>` call sites.
**Change:** Maintain a persistent flat embedding buffer alongside
`cache.embeddings` (updated incrementally on insert/delete) and call
`blas_cosine_batch_flat` instead of `blas_cosine_batch` from both files'
query paths.
### INT-17 — `crates/clawhdf5-agent/src/entity_extract.rs` (`dedup_overlapping`, lines 302313)
**Problem:** `result.iter().any(|existing| ...)` checks every candidate
entity against all already-accepted entities — `O(n²)` in
entities-per-extraction-call. This runs at ingestion time (every memory
save), not query time, and is bounded by entities-per-chunk (typically
small), so it's lower priority than INT-11 through INT-16.
**Change:** If profiling shows this matters in practice (large chunks with
many extracted entities), replace with a spatial/interval-based overlap
index; otherwise leave as-is — flagging for completeness, not urgency.
---
## Summary table
| INT | Area | File(s) | Category |
|-----|------|---------|----------|
| INT-01 | Parser crash safety | `fixed_array.rs`, `extensible_array.rs` | Security |
| INT-02 | Parser crash safety | `symbol_table.rs` | Security |
| INT-03 | Parser crash safety | `datatype.rs` | Security |
| INT-04 | Provenance wiring | `provenance.rs`, `anomaly.rs`, `lib.rs` | Provenance |
| INT-05 | Source trust boundary | `lib.rs`, `consolidation.rs` | Provenance |
| INT-06 | Anomaly pattern bypass | `anomaly.rs` | Provenance |
| INT-07 | Rate-limit attribution | `anomaly.rs` | Provenance |
| INT-08 | Integrity verification unwired | `clawhdf5-format/provenance.rs` | Provenance |
| INT-09 | WAL ordering/legacy fallback | `wal.rs` | Provenance |
| INT-10 | Char-boundary panic | `clawhdf5-migrate/validate.rs` | Correctness |
| INT-11 | WAND top-k re-sort | `bm25.rs` | Performance |
| INT-12 | Entity resolution scan | `knowledge.rs` | Performance |
| INT-13 | Graph traversal scan | `knowledge.rs` | Performance |
| INT-14 | Unneeded clone | `consolidation.rs` | Performance |
| INT-15 | O(n·m) eviction | `consolidation.rs` | Performance |
| INT-16 | Per-query re-flatten | `blas_search.rs`, `accelerate_search.rs` | Performance |
| INT-17 | O(n²) dedup (low priority) | `entity_extract.rs` | Performance |
## Follow-ups for the coding phase
TASK: INT-01 — Fix unchecked-overflow bounds checks in fixed_array.rs/extensible_array.rs
TASK: INT-02 — Fix unchecked-overflow bounds check in symbol_table.rs
TASK: INT-03 — Add recursion-depth guard to Datatype::parse
TASK: INT-04 — Wire provenance.rs/anomaly.rs into save/load path
TASK: INT-05 — Enforce source-of-truth for MemorySource/source_channel at trust boundary
TASK: INT-06 — Harden anomaly pattern matching against whitespace/homoglyph bypass
TASK: INT-07 — Make anomaly rate-limit window per-source
TASK: INT-08 — Wire clawhdf5-format provenance verify_dataset into read path
TASK: INT-09 — Add WAL entry ordering protection and restrict legacy no-CRC fallback
TASK: INT-10 — Fix byte-index slice panic in clawhdf5-migrate validate.rs truncate()
TASK: INT-11 — Replace BM25 top-k re-sort with a min-heap
TASK: INT-12 — Cache lowercased entity names and early-exit in resolve_or_create
TASK: INT-13 — Add adjacency index for knowledge graph traversal functions
TASK: INT-14 — Avoid cloning working-tier records in consolidation add_memory
TASK: INT-15 — Use HashSet for eviction ID membership checks in consolidation
TASK: INT-16 — Use persistent flat embedding buffer in blas_search/accelerate_search
TASK: INT-17 — (optional/low-priority) revisit entity_extract dedup_overlapping if profiling shows it matters