polyc-query 2026.8.3

Read layer over the event log: a DataFusion engine for SQL over replayed partitions, and a per-conversation Parquet projection for participation-scoped search (docs/reference/datafusion-data-layer.md, docs/proposals/participation-scoped-agent-search.md).
//! Keyed term hashing for the search index.
//!
//! The index stores hashes of the words a conversation contains, never the
//! words. That reduces exposure but does not eliminate it: a term-membership
//! record is an oracle over user text by construction — anyone who can read it
//! can test whether a guessed word appears — and it is MORE precise for short
//! conversations, which are most of them.
//!
//! Keying is what closes that. [`TermKey`] wraps a per-deployment secret, and
//! every hash goes through `blake3::keyed_hash`, so testing a guess offline
//! requires the key rather than only the store. Rotating the key invalidates
//! every record and forces a rebuild — deliberately, since a stale record
//! hashed under the old key would silently stop matching.
//!
//! Tokenization is [`polyc_eventlog::nav::distinct_terms_of`], not a private
//! copy. An index and a live replay that disagreed on what a word is would
//! answer the same query differently, and the difference would show up as
//! missing hits rather than as an error.

use polyc_eventlog::nav::distinct_terms_of;

/// Bits kept from each term's hash.
///
/// 24 bits leaves a 16.7-million-value space. Against the few thousand
/// distinct terms a large conversation holds, a query term that is genuinely
/// absent collides with a stored one well under a tenth of a percent of the
/// time — and a collision costs one wasted partition read, never a wrong
/// answer, because the postings stage re-checks every candidate.
const TERM_HASH_BITS: u32 = 24;

/// Mask selecting [`TERM_HASH_BITS`] from a hash's leading bytes.
const TERM_HASH_MASK: u32 = (1 << TERM_HASH_BITS) - 1;

/// Domain separator mixed into every term hash, so a term hash can never
/// collide by construction with a digest this codebase computes for some other
/// purpose under the same key.
const TERM_DOMAIN: &[u8] = b"polychrome.search.term.v1";

/// Domain separator for [`TermKey::key_id`], distinct from [`TERM_DOMAIN`] so
/// a key's published identity can never collide with a term hash under it.
const KEY_ID_DOMAIN: &[u8] = b"polychrome.search.key-id.v1";

/// The per-deployment secret every stored term hash is computed under.
///
/// Held by value rather than borrowed because it outlives every individual
/// index operation and is cheap to clone. Deliberately does NOT implement
/// `Debug` or `Display`: a key that can be printed is a key that ends up in a
/// log line.
#[derive(Clone)]
pub(crate) struct TermKey([u8; 32]);

impl TermKey {
    /// Build a key from 32 bytes of per-deployment secret material.
    pub(crate) const fn new(secret: [u8; 32]) -> Self {
        Self(secret)
    }

    /// A stable, non-reversible identity for this key, lower-hex.
    ///
    /// Written into every projection file's footer so a rotation is DETECTED
    /// rather than silently answering zero hits for text the file holds.
    /// Domain-separated from the term hash so it can never be confused with
    /// one, and derived rather than stored so a caller cannot pass the wrong
    /// identity for the key it is actually hashing with.
    pub(crate) fn key_id(&self) -> String {
        let mut hasher = blake3::Hasher::new_keyed(&self.0);
        hasher.update(KEY_ID_DOMAIN);
        hasher
            .finalize()
            .as_bytes()
            .iter()
            .take(16)
            .fold(String::new(), |mut out, byte| {
                use std::fmt::Write as _;
                let _ = write!(out, "{byte:02x}");
                out
            })
    }

    /// The keyed, truncated hash of one already-tokenized term.
    ///
    /// Truncation takes the low [`TERM_HASH_BITS`] of the first four bytes
    /// read little-endian. Which bits are kept does not matter for collision
    /// behavior — BLAKE3's output is uniform — but it must never change
    /// without a rebuild, so it is pinned here rather than left to a caller.
    pub(crate) fn hash_term(&self, term: &str) -> u32 {
        let mut hasher = blake3::Hasher::new_keyed(&self.0);
        hasher.update(TERM_DOMAIN);
        hasher.update(&[0x00]);
        hasher.update(term.as_bytes());
        let digest = hasher.finalize();
        let bytes = digest.as_bytes();
        u32::from_le_bytes([bytes[0], bytes[1], bytes[2], bytes[3]]) & TERM_HASH_MASK
    }

    /// The sorted, deduplicated keyed hashes of every searchable term in
    /// `text`.
    ///
    /// Non-ASCII terms are skipped at index time. The live lexical core falls
    /// back to case-insensitive substring matching for them
    /// (`crates/eventlog-model/src/nav.rs`), which no exact-term index reproduces
    /// without character n-grams — so storing them would consume record
    /// capacity for hashes that can never match. Cross-conversation search
    /// covers tokenized terms only, and says so at the tool surface.
    pub(crate) fn hash_text(&self, text: &str) -> Vec<u32> {
        let mut hashes: Vec<u32> = distinct_terms_of(text)
            .iter()
            .filter(|term| term.is_ascii())
            .map(|term| self.hash_term(term))
            .collect();
        hashes.sort_unstable();
        hashes.dedup();
        hashes
    }
}

#[cfg(test)]
mod tests {
    #![allow(clippy::pedantic, clippy::nursery, missing_docs)]

    use super::*;

    fn key() -> TermKey {
        TermKey::new([7u8; 32])
    }

    #[test]
    fn hashes_are_stable_for_the_same_key_and_term() {
        assert_eq!(key().hash_term("timeout"), key().hash_term("timeout"));
    }

    #[test]
    fn hashes_fit_the_declared_width() {
        for term in ["a", "timeout", "polychrome", "0123456789"] {
            assert!(
                key().hash_term(term) <= TERM_HASH_MASK,
                "{term} hashed outside the {TERM_HASH_BITS}-bit space"
            );
        }
    }

    /// The whole point of keying: the same word under a different deployment
    /// secret is a different hash, so a stolen store does not yield a
    /// dictionary attack without the key.
    #[test]
    fn a_different_key_yields_a_different_hash() {
        let other = TermKey::new([9u8; 32]);
        assert_ne!(key().hash_term("timeout"), other.hash_term("timeout"));
    }

    #[test]
    fn text_hashes_are_sorted_deduplicated_and_case_insensitive() {
        let hashes = key().hash_text("Timeout timeout TIMEOUT decide");

        assert_eq!(hashes.len(), 2, "one hash per distinct term: {hashes:?}");
        assert!(hashes.windows(2).all(|w| w[0] < w[1]), "sorted and deduped");
        assert_eq!(hashes, key().hash_text("decide timeout"));
    }

    /// Punctuation is a separator, matching the live tokenizer rather than a
    /// private copy of it.
    #[test]
    fn tokenization_matches_the_live_lexical_core() {
        assert_eq!(
            key().hash_text("where-did/we decide?"),
            key().hash_text("we did where decide")
        );
    }

    /// Non-ASCII terms consume capacity for hashes that can never match, since
    /// V1 offers no substring path across conversations.
    #[test]
    fn non_ascii_terms_are_skipped_at_index_time() {
        let hashes = key().hash_text("timeout \u{4f60}\u{597d} decide");

        assert_eq!(
            hashes,
            key().hash_text("timeout decide"),
            "a non-ASCII term must not occupy a slot it can never match from"
        );
    }

    /// The identity must be stable, or every restart would invalidate the
    /// projection it is meant to validate.
    #[test]
    fn key_id_is_stable_for_the_same_key() {
        assert_eq!(key().key_id(), key().key_id());
    }

    /// The whole point: a rotated key must produce a different identity, or
    /// the footer check cannot detect the rotation it exists for.
    #[test]
    fn a_different_key_yields_a_different_key_id() {
        assert_ne!(key().key_id(), TermKey::new([9u8; 32]).key_id());
    }

    /// The identity is domain-separated from term hashing, so it can never be
    /// mistaken for -- or collide with -- a term hash under the same key.
    #[test]
    fn the_key_id_is_not_derivable_as_a_term_hash() {
        let id = key().key_id();
        assert!(!id.is_empty());
        assert!(id.chars().all(|c| c.is_ascii_hexdigit()));
    }

    #[test]
    fn empty_and_punctuation_only_text_yields_no_hashes() {
        assert!(key().hash_text("").is_empty());
        assert!(key().hash_text("   ---  ??? ").is_empty());
    }
}